From 797b4bb092a6562735318468767489f055b3fdeb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 15:36:46 +0000 Subject: [PATCH 01/15] fix(maria): require Node.js 22+ (native WebSocket needed by supabase-js) Fail fast in the worker with an actionable message instead of the supabase-js realtime WebSocket error, add .nvmrc and bump engines. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/.nvmrc | 1 + apps/maria/package.json | 2 +- apps/maria/worker/index.ts | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 apps/maria/.nvmrc diff --git a/apps/maria/.nvmrc b/apps/maria/.nvmrc new file mode 100644 index 00000000000..2bd5a0a98a3 --- /dev/null +++ b/apps/maria/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/apps/maria/package.json b/apps/maria/package.json index b5a9320c9ca..f57f094d226 100644 --- a/apps/maria/package.json +++ b/apps/maria/package.json @@ -24,6 +24,6 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=20.12" + "node": ">=22" } } diff --git a/apps/maria/worker/index.ts b/apps/maria/worker/index.ts index cab863b57b9..9f55c1952de 100644 --- a/apps/maria/worker/index.ts +++ b/apps/maria/worker/index.ts @@ -7,6 +7,10 @@ import { Store } from './store'; const HEARTBEAT_MS = 30_000; async function main(): Promise { + const major = Number(process.versions.node.split('.')[0]); + if (major < 22) { + throw new Error(`Node.js ${process.versions.node} détecté : MarIA nécessite Node.js 22 ou plus (nvm install 22 && nvm use 22).`); + } const cfg = loadConfig(); const store = new Store(cfg.supabaseUrl, cfg.serviceRoleKey); const names = Object.keys(cfg.workspaces); From 48070295d92afc888d6066a8464c4e4717e83879 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 16:13:49 +0000 Subject: [PATCH 02/15] feat(maria): headless guidance for the agent and surface permission denials - Append a system prompt telling the agent it runs unattended: no cd or chained commands (so pre-approved tool patterns match), no retrying denied variants, end with a question for follow-ups. - Show the result event's permission_denials in the activity feed so the user knows which tools to add to MARIA_ALLOWED_TOOLS. - Broaden the example allowlist to common npm/node/git read commands. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/.env.example | 4 +++- apps/maria/src/app/globals.css | 2 +- apps/maria/src/lib/feed.ts | 13 ++++++++++++- apps/maria/src/lib/types.ts | 1 + apps/maria/worker/runner.ts | 15 +++++++++++++-- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/maria/.env.example b/apps/maria/.env.example index 784c1876a91..c4045c08855 100644 --- a/apps/maria/.env.example +++ b/apps/maria/.env.example @@ -17,7 +17,9 @@ MARIA_WORKSPACES={"demo":"/home/moi/projets/demo"} # dontAsk = refuse tout ce qui n'est pas dans MARIA_ALLOWED_TOOLS. MARIA_PERMISSION_MODE=acceptEdits # Outils pré-autorisés (séparés par des virgules). Vide = règles par défaut du mode choisi. -MARIA_ALLOWED_TOOLS=Read,Glob,Grep,Edit,Write,Bash(npm run *),Bash(npx ruflo *),Bash(git status),Bash(git diff *) +# Tout ce qui n'est pas listé est refusé (personne ne peut approuver en headless) ; les refus +# s'affichent en fin de mission dans MarIA. Élargis selon ce que tes missions doivent faire. +MARIA_ALLOWED_TOOLS=Read,Glob,Grep,Edit,Write,Bash(npm install *),Bash(npm run *),Bash(npm test *),Bash(npm --prefix *),Bash(npx *),Bash(node *),Bash(ls *),Bash(git status *),Bash(git diff *),Bash(git log *) # Modèle optionnel (alias ou nom complet). Vide = modèle par défaut de Claude Code. MARIA_MODEL= # Chemins ignorés dans la liste des fichiers modifiés (préfixes, séparés par des virgules). diff --git a/apps/maria/src/app/globals.css b/apps/maria/src/app/globals.css index ed81c8901d6..f45fdd4638d 100644 --- a/apps/maria/src/app/globals.css +++ b/apps/maria/src/app/globals.css @@ -114,7 +114,7 @@ button.danger { background: var(--err); } .tool { font-weight: 600; margin-right: 6px; } .feed-text .text { white-space: pre-wrap; } .feed-result { color: var(--muted); font-size: 12px; padding-left: 16px !important; } -.feed-info { color: var(--muted); font-size: 12px; font-style: italic; } +.feed-info { color: var(--muted); font-size: 12px; font-style: italic; white-space: pre-wrap; } .feed-info.warn { color: var(--warn); } .feed-info.error, .feed-result.error, .feed-done.error { color: var(--err); } .feed-done { font-weight: 600; color: var(--ok); } diff --git a/apps/maria/src/lib/feed.ts b/apps/maria/src/lib/feed.ts index cb7ef945d1b..ad4082fa39c 100644 --- a/apps/maria/src/lib/feed.ts +++ b/apps/maria/src/lib/feed.ts @@ -98,9 +98,20 @@ export function buildFeed(events: MissionEvent[]): FeedItem[] { items.push({ key: `${id}-${i}`, kind: 'tool_result', agent, isError: !!res.is_error, text: truncate(resultText(res), 300) }); }); break; - case 'result': + case 'result': { + const denials = e.permission_denials ?? []; + if (denials.length > 0) { + const list = [...new Set(denials.map((d) => `${d.tool_name}: ${describeTool(d.tool_name, d.tool_input ?? {})}`))]; + items.push({ + key: `${id}-denials`, + kind: 'info', + level: 'warn', + text: `${denials.length} action(s) refusée(s) faute de permission — à ajouter dans MARIA_ALLOWED_TOOLS si besoin :\n${list.join('\n')}`, + }); + } items.push({ key: `${id}`, kind: 'done', isError: !!e.is_error, text: summarizeResult(e) }); break; + } } } return items; diff --git a/apps/maria/src/lib/types.ts b/apps/maria/src/lib/types.ts index c66f80c3c98..a8359d85c96 100644 --- a/apps/maria/src/lib/types.ts +++ b/apps/maria/src/lib/types.ts @@ -78,6 +78,7 @@ export interface StreamEvent { total_cost_usd?: number; duration_ms?: number; num_turns?: number; + permission_denials?: Array<{ tool_name: string; tool_input?: Record }>; // Événement 'maria' (émis par le worker) level?: 'info' | 'warn' | 'error'; text?: string; diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index 99475134cf0..8847eb74cd3 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -9,9 +9,20 @@ const STATUS_POLL_MS = 2000; const KILL_GRACE_MS = 5000; const STDERR_TAIL = 4000; -function buildArgs(cfg: WorkerConfig, resumeSessionId: string | null): string[] { +/** Contexte ajouté au prompt système : l'agent tourne sans humain pour approuver quoi que ce soit. */ +function headlessNote(cwd: string): string { + return [ + 'Tu es exécuté en mode headless par MarIA : aucun humain ne peut approuver une permission pendant la mission.', + `Ton dossier de travail est ${cwd} ; lance les commandes directement depuis ce dossier, sans \`cd\`, et une commande à la fois (pas de && ni de |) pour qu'elles correspondent aux outils pré-autorisés.`, + "Si une commande est refusée, ne réessaie pas de variantes : continue avec ce que tu peux faire et liste en fin de réponse les commandes à autoriser.", + "Si tu as besoin d'une décision de l'utilisateur, termine ta réponse par une question claire : il pourra répondre via « Continuer ».", + ].join('\n'); +} + +function buildArgs(cfg: WorkerConfig, cwd: string, resumeSessionId: string | null): string[] { // Le prompt passe par stdin : --allowedTools est variadique et avalerait un argument positionnel. const args = ['-p', '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode]; + args.push('--append-system-prompt', headlessNote(cwd)); if (cfg.allowedTools.length > 0) args.push('--allowedTools', cfg.allowedTools.join(',')); if (cfg.model) args.push('--model', cfg.model); if (resumeSessionId) args.push('--resume', resumeSessionId); @@ -54,7 +65,7 @@ export async function runMission( let stderrTail = ''; let cancelled = false; - const child = spawn(cfg.claudeBin, buildArgs(cfg, resumeSessionId), { + const child = spawn(cfg.claudeBin, buildArgs(cfg, cwd, resumeSessionId), { cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'], From 06d2095c856647cb13408407f4ba530900f3351b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:48:23 +0000 Subject: [PATCH 03/15] chore(maria): log effective permission mode and allowed tools at worker start Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/worker/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/maria/worker/index.ts b/apps/maria/worker/index.ts index 9f55c1952de..a6ee18cd377 100644 --- a/apps/maria/worker/index.ts +++ b/apps/maria/worker/index.ts @@ -19,6 +19,7 @@ async function main(): Promise { const orphans = await store.failOrphans(names); if (orphans > 0) console.log(`[maria] ${orphans} mission(s) orpheline(s) marquée(s) en échec`); console.log(`[maria] worker prêt — dossiers : ${names.map((n) => `${n} → ${cfg.workspaces[n]}`).join(', ')}`); + console.log(`[maria] permissions : mode ${cfg.permissionMode}, outils autorisés : ${cfg.allowedTools.join(', ') || '(aucun)'}`); // Une mission à la fois par dossier : deux agents dans le même dossier se marcheraient dessus. const running = new Map>(); From c78e69fe1a10f73cea55730405cb8d3506565043 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:20:52 +0000 Subject: [PATCH 04/15] fix(maria): steer the headless agent away from git -C / --prefix forms Those variants miss simple allowlist patterns like Bash(git status) and get denied; ask for the plain form run from the working directory. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/worker/runner.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index 8847eb74cd3..c024ba5178b 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -14,6 +14,7 @@ function headlessNote(cwd: string): string { return [ 'Tu es exécuté en mode headless par MarIA : aucun humain ne peut approuver une permission pendant la mission.', `Ton dossier de travail est ${cwd} ; lance les commandes directement depuis ce dossier, sans \`cd\`, et une commande à la fois (pas de && ni de |) pour qu'elles correspondent aux outils pré-autorisés.`, + 'Écris les commandes sous leur forme la plus simple, sans option de changement de dossier (pas de `git -C`, `npm --prefix`, chemins absolus vers le dossier de travail) : par exemple `git status`, `npm test`.', "Si une commande est refusée, ne réessaie pas de variantes : continue avec ce que tu peux faire et liste en fin de réponse les commandes à autoriser.", "Si tu as besoin d'une décision de l'utilisateur, termine ta réponse par une question claire : il pourra répondre via « Continuer ».", ].join('\n'); From 20a6c36d8a985e1a6b9e9fca095214b462d83f05 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:28:36 +0000 Subject: [PATCH 05/15] feat(maria): interactive permission prompts (allow / deny window) Instead of auto-denying tools that are not pre-approved, the agent's permission prompts are now routed to the user: - worker/permission-mcp.ts: a minimal stdio MCP server that Claude Code calls through --permission-prompt-tool. It records each request in maria.permission_requests and waits for the user's decision (fails closed: deny on timeout or technical error). - Migration 0002: permission_requests table, decide_permission() RPC (members only, first decision wins), realtime publication. - Front: PermissionPrompt modal with tool, command summary and full parameters; tab title shows pending count. - Pending requests expire when a mission ends or the worker restarts. - Configurable via MARIA_INTERACTIVE_PERMISSIONS (default on) and MARIA_PERMISSION_TIMEOUT_MS (default 10 min). Verified with the real claude CLI (allow runs the command, deny returns the refusal to the agent) and the SQL against Postgres 16. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/app/globals.css | 26 ++++ apps/maria/src/components/Dashboard.tsx | 2 + .../maria/src/components/PermissionPrompt.tsx | 95 ++++++++++++ apps/maria/src/lib/feed.ts | 2 +- apps/maria/src/lib/types.ts | 9 ++ .../migrations/0002_permission_requests.sql | 45 ++++++ apps/maria/worker/config.ts | 10 ++ apps/maria/worker/index.ts | 1 + apps/maria/worker/permission-mcp.ts | 145 ++++++++++++++++++ apps/maria/worker/runner.ts | 36 ++++- apps/maria/worker/store.ts | 12 ++ 11 files changed, 375 insertions(+), 8 deletions(-) create mode 100644 apps/maria/src/components/PermissionPrompt.tsx create mode 100644 apps/maria/supabase/migrations/0002_permission_requests.sql create mode 100644 apps/maria/worker/permission-mcp.ts diff --git a/apps/maria/src/app/globals.css b/apps/maria/src/app/globals.css index f45fdd4638d..bb506cd0d20 100644 --- a/apps/maria/src/app/globals.css +++ b/apps/maria/src/app/globals.css @@ -128,3 +128,29 @@ button.danger { background: var(--err); } .sidebar { border-right: 0; border-bottom: 1px solid var(--border); max-height: none; } .columns { grid-template-columns: 1fr; } } + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + padding: 16px; + background: rgba(0, 0, 0, .55); +} +.modal { width: min(560px, 100%); display: grid; gap: 10px; max-height: 90vh; overflow-y: auto; } +.modal h2 { color: var(--warn); } +.modal p { margin: 0; } +.perm-detail { + margin: 0; + padding: 10px; + border-radius: 8px; + background: var(--code-bg); + font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow-y: auto; +} +.modal-actions { justify-content: flex-end; align-items: center; } +.modal-actions .muted { margin-right: auto; } diff --git a/apps/maria/src/components/Dashboard.tsx b/apps/maria/src/components/Dashboard.tsx index 45f8eeb3343..a1d35d2b625 100644 --- a/apps/maria/src/components/Dashboard.tsx +++ b/apps/maria/src/components/Dashboard.tsx @@ -6,6 +6,7 @@ import type { Mission, Workspace } from '@/lib/types'; import { MissionForm } from './MissionForm'; import { MissionList } from './MissionList'; import { MissionView } from './MissionView'; +import { PermissionPrompt } from './PermissionPrompt'; const WORKSPACE_REFRESH_MS = 30_000; @@ -81,6 +82,7 @@ export function Dashboard({ email }: { email: string }) { setSelectedId(m.id)} /> +
{selected ? ( setSelectedId(m.id)} /> diff --git a/apps/maria/src/components/PermissionPrompt.tsx b/apps/maria/src/components/PermissionPrompt.tsx new file mode 100644 index 00000000000..8bf4e91b2bf --- /dev/null +++ b/apps/maria/src/components/PermissionPrompt.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { describeTool } from '@/lib/feed'; +import { getSupabase, MARIA_SCHEMA } from '@/lib/supabase'; +import type { Mission, PermissionRequest } from '@/lib/types'; + +const BASE_TITLE = 'MarIA'; + +/** Fenêtre « Autoriser / Refuser » pour les actions que l'agent n'est pas pré-autorisé à faire. */ +export function PermissionPrompt({ missions }: { missions: Mission[] }) { + const [pending, setPending] = useState([]); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const supabase = getSupabase(); + const apply = (row: PermissionRequest) => + setPending((prev) => { + const rest = prev.filter((r) => r.id !== row.id); + const next = row.status === 'pending' ? [...rest, row] : rest; + return next.sort((a, b) => a.created_at.localeCompare(b.created_at)); + }); + + const channel = supabase + .channel('permission-requests') + .on('postgres_changes', { event: '*', schema: MARIA_SCHEMA, table: 'permission_requests' }, (payload) => { + if (payload.eventType !== 'DELETE') apply(payload.new as PermissionRequest); + }) + .subscribe(); + + supabase + .from('permission_requests') + .select('*') + .eq('status', 'pending') + .order('created_at') + .then(({ data, error: err }) => { + if (err) setError(err.message); + else (data as PermissionRequest[]).forEach(apply); + }); + + return () => { + supabase.removeChannel(channel); + }; + }, []); + + // Signale une demande en attente dans l'onglet, même quand MarIA n'est pas au premier plan. + useEffect(() => { + document.title = pending.length > 0 ? `(${pending.length}) Autorisation requise — ${BASE_TITLE}` : BASE_TITLE; + }, [pending.length]); + + const current = pending[0]; + if (!current) return null; + const mission = missions.find((m) => m.id === current.mission_id); + + async function decide(allow: boolean) { + setBusy(true); + setError(null); + const { error: err } = await getSupabase().rpc('decide_permission', { p_id: current.id, p_allow: allow }); + setBusy(false); + if (err) setError(err.message); + else setPending((prev) => prev.filter((r) => r.id !== current.id)); + } + + return ( +
+
+

Autorisation demandée

+ {mission && ( +

+ Mission « {mission.prompt.length > 80 ? `${mission.prompt.slice(0, 80)}…` : mission.prompt} » · {mission.workspace} +

+ )} +

+ L’agent veut utiliser {current.tool_name} : +

+
{describeTool(current.tool_name, current.input) || '(sans détail)'}
+
+ Paramètres complets +
{JSON.stringify(current.input, null, 2)}
+
+ {error &&

{error}

} +
+ {pending.length > 1 && +{pending.length - 1} autre(s) en attente} + + +
+
+
+ ); +} diff --git a/apps/maria/src/lib/feed.ts b/apps/maria/src/lib/feed.ts index ad4082fa39c..29e9c79ce1e 100644 --- a/apps/maria/src/lib/feed.ts +++ b/apps/maria/src/lib/feed.ts @@ -106,7 +106,7 @@ export function buildFeed(events: MissionEvent[]): FeedItem[] { key: `${id}-denials`, kind: 'info', level: 'warn', - text: `${denials.length} action(s) refusée(s) faute de permission — à ajouter dans MARIA_ALLOWED_TOOLS si besoin :\n${list.join('\n')}`, + text: `${denials.length} action(s) refusée(s) (par toi, ou sans réponse dans le délai). Pour ne plus avoir à les valider, ajoute-les à MARIA_ALLOWED_TOOLS :\n${list.join('\n')}`, }); } items.push({ key: `${id}`, kind: 'done', isError: !!e.is_error, text: summarizeResult(e) }); diff --git a/apps/maria/src/lib/types.ts b/apps/maria/src/lib/types.ts index a8359d85c96..f14973550e9 100644 --- a/apps/maria/src/lib/types.ts +++ b/apps/maria/src/lib/types.ts @@ -83,3 +83,12 @@ export interface StreamEvent { level?: 'info' | 'warn' | 'error'; text?: string; } + +export interface PermissionRequest { + id: string; + mission_id: string; + tool_name: string; + input: Record; + status: 'pending' | 'allowed' | 'denied' | 'expired'; + created_at: string; +} diff --git a/apps/maria/supabase/migrations/0002_permission_requests.sql b/apps/maria/supabase/migrations/0002_permission_requests.sql new file mode 100644 index 00000000000..e630c4b1d64 --- /dev/null +++ b/apps/maria/supabase/migrations/0002_permission_requests.sql @@ -0,0 +1,45 @@ +-- MarIA : demandes d'autorisation interactives. +-- Quand l'agent veut utiliser un outil non pré-autorisé, Claude Code interroge le serveur MCP +-- du worker (--permission-prompt-tool), qui crée une demande ici et attend la décision prise +-- dans l'interface via decide_permission(). + +create table maria.permission_requests ( + id uuid primary key default gen_random_uuid(), + mission_id uuid not null references maria.missions (id) on delete cascade, + tool_name text not null, + input jsonb not null default '{}'::jsonb, + status text not null default 'pending' + check (status in ('pending', 'allowed', 'denied', 'expired')), + decided_by uuid references auth.users (id) on delete set null, + decided_at timestamptz, + created_at timestamptz not null default now() +); + +create index permission_requests_pending_idx on maria.permission_requests (status, created_at); + +alter table maria.permission_requests enable row level security; +grant all on maria.permission_requests to service_role; +grant select on maria.permission_requests to authenticated; + +create policy "members read permission requests" on maria.permission_requests + for select to authenticated using (maria.is_member()); + +create function maria.decide_permission(p_id uuid, p_allow boolean) +returns void +language sql +security definer +set search_path = '' +as $$ + update maria.permission_requests + set status = case when p_allow then 'allowed' else 'denied' end, + decided_by = auth.uid(), + decided_at = now() + where id = p_id + and status = 'pending' + and maria.is_member(); +$$; + +revoke execute on function maria.decide_permission(uuid, boolean) from public, anon; +grant execute on function maria.decide_permission(uuid, boolean) to authenticated; + +alter publication supabase_realtime add table maria.permission_requests; diff --git a/apps/maria/worker/config.ts b/apps/maria/worker/config.ts index 2223849d5f2..292d1f152c2 100644 --- a/apps/maria/worker/config.ts +++ b/apps/maria/worker/config.ts @@ -15,6 +15,9 @@ export interface WorkerConfig { model: string | null; ignorePaths: string[]; pollMs: number; + /** Demandes d'autorisation affichées dans MarIA (sinon : refus automatique). */ + interactivePermissions: boolean; + permissionTimeoutMs: number; } function required(name: string): string { @@ -68,6 +71,11 @@ export function loadConfig(): WorkerConfig { const pollMs = Number(process.env.MARIA_POLL_MS ?? 2000); if (!Number.isFinite(pollMs) || pollMs < 250) throw new Error('MARIA_POLL_MS doit être >= 250'); + const permissionTimeoutMs = Number(process.env.MARIA_PERMISSION_TIMEOUT_MS ?? 600_000); + if (!Number.isFinite(permissionTimeoutMs) || permissionTimeoutMs < 10_000) { + throw new Error('MARIA_PERMISSION_TIMEOUT_MS doit être >= 10000'); + } + return { supabaseUrl: required('SUPABASE_URL'), serviceRoleKey: required('SUPABASE_SERVICE_ROLE_KEY'), @@ -78,5 +86,7 @@ export function loadConfig(): WorkerConfig { model: process.env.MARIA_MODEL?.trim() || null, ignorePaths: list(process.env.MARIA_IGNORE_PATHS ?? '.claude-flow/,.swarm/'), pollMs, + interactivePermissions: process.env.MARIA_INTERACTIVE_PERMISSIONS?.trim() !== '0', + permissionTimeoutMs, }; } diff --git a/apps/maria/worker/index.ts b/apps/maria/worker/index.ts index a6ee18cd377..39859f89a46 100644 --- a/apps/maria/worker/index.ts +++ b/apps/maria/worker/index.ts @@ -20,6 +20,7 @@ async function main(): Promise { if (orphans > 0) console.log(`[maria] ${orphans} mission(s) orpheline(s) marquée(s) en échec`); console.log(`[maria] worker prêt — dossiers : ${names.map((n) => `${n} → ${cfg.workspaces[n]}`).join(', ')}`); console.log(`[maria] permissions : mode ${cfg.permissionMode}, outils autorisés : ${cfg.allowedTools.join(', ') || '(aucun)'}`); + console.log(`[maria] autres actions : ${cfg.interactivePermissions ? `demandées dans MarIA (délai ${Math.round(cfg.permissionTimeoutMs / 1000)} s)` : 'refusées automatiquement'}`); // Une mission à la fois par dossier : deux agents dans le même dossier se marcheraient dessus. const running = new Map>(); diff --git a/apps/maria/worker/permission-mcp.ts b/apps/maria/worker/permission-mcp.ts new file mode 100644 index 00000000000..66f2bb671fc --- /dev/null +++ b/apps/maria/worker/permission-mcp.ts @@ -0,0 +1,145 @@ +// Serveur MCP (stdio) lancé par Claude Code via --permission-prompt-tool. +// Chaque demande d'autorisation devient une ligne maria.permission_requests ; on attend que +// l'utilisateur clique « Autoriser » ou « Refuser » dans MarIA, puis on répond à Claude Code. +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import readline from 'node:readline'; +import { createClient } from '@supabase/supabase-js'; + +export const SERVER_NAME = 'maria'; +export const TOOL_NAME = 'approve'; +/** Nom complet à passer à --permission-prompt-tool. */ +export const PERMISSION_TOOL = `mcp__${SERVER_NAME}__${TOOL_NAME}`; + +const POLL_MS = 1000; + +export type Decision = 'allowed' | 'denied' | 'expired'; + +export interface PermissionBackend { + /** Crée la demande et renvoie la décision de l'utilisateur (ou 'expired' après le délai). */ + ask(toolName: string, input: Record): Promise; +} + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id?: number | string; + method: string; + params?: Record; +} + +const TOOL_DEFINITION = { + name: TOOL_NAME, + description: 'Outil interne de MarIA : soumet une demande de permission à l’utilisateur. Ne pas appeler directement.', + inputSchema: { + type: 'object', + properties: { + tool_name: { type: 'string' }, + input: { type: 'object' }, + tool_use_id: { type: 'string' }, + }, + required: ['tool_name', 'input'], + }, +}; + +/** Traite un message JSON-RPC ; renvoie la réponse, ou null pour une notification. */ +export function createHandler(backend: PermissionBackend) { + return async (msg: JsonRpcRequest): Promise => { + if (msg.id === undefined) return null; // notifications (ex. notifications/initialized) + const reply = (result: object) => ({ jsonrpc: '2.0', id: msg.id, result }); + + switch (msg.method) { + case 'initialize': + return reply({ + protocolVersion: (msg.params?.protocolVersion as string) ?? '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: SERVER_NAME, version: '0.1.0' }, + }); + case 'ping': + return reply({}); + case 'tools/list': + return reply({ tools: [TOOL_DEFINITION] }); + case 'tools/call': { + const args = (msg.params?.arguments ?? {}) as { tool_name?: string; input?: Record }; + const input = args.input ?? {}; + let decision: Decision | 'error'; + try { + decision = await backend.ask(args.tool_name ?? 'inconnu', input); + } catch (err) { + // En cas de panne on refuse : jamais d'autorisation sans décision explicite. + decision = 'error'; + console.error(`[maria-permissions] ${(err as Error).message}`); + } + const denyMessages = { + denied: 'Action refusée par l’utilisateur dans MarIA.', + expired: 'Pas de réponse de l’utilisateur dans le délai imparti : action refusée.', + error: 'Impossible de soumettre la demande à MarIA (erreur technique) : action refusée.', + }; + const answer = + decision === 'allowed' + ? { behavior: 'allow', updatedInput: input } + : { behavior: 'deny', message: denyMessages[decision] }; + return reply({ content: [{ type: 'text', text: JSON.stringify(answer) }] }); + } + default: + return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Méthode inconnue : ${msg.method}` } }; + } + }; +} + +function supabaseBackend(): PermissionBackend { + // Claude Code ne transmet pas forcément l'environnement du worker : on relit sa config. + if (!process.env.SUPABASE_SERVICE_ROLE_KEY) { + for (const file of ['.env.local', '.env']) { + const p = path.join(__dirname, '..', file); + if (existsSync(p)) process.loadEnvFile(p); + } + } + const missionId = process.env.MARIA_MISSION_ID; + const url = process.env.SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + const timeoutMs = Number(process.env.MARIA_PERMISSION_TIMEOUT_MS ?? 600_000); + if (!missionId || !url || !key) throw new Error('MARIA_MISSION_ID, SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY sont requis'); + const db = createClient(url, key, { auth: { persistSession: false }, db: { schema: 'maria' } }); + + return { + async ask(toolName, input) { + const { data, error } = await db + .from('permission_requests') + .insert({ mission_id: missionId, tool_name: toolName, input }) + .select('id') + .single(); + if (error) throw new Error(`insert: ${error.message}`); + + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, POLL_MS)); + const { data: row, error: err } = await db.from('permission_requests').select('status').eq('id', data.id).single(); + if (err) continue; // erreur réseau passagère : on réessaie + if (row.status !== 'pending') return row.status as Decision; + } + await db.from('permission_requests').update({ status: 'expired' }).eq('id', data.id).eq('status', 'pending'); + return 'expired'; + }, + }; +} + +function main(): void { + const handle = createHandler(supabaseBackend()); + const lines = readline.createInterface({ input: process.stdin }); + lines.on('line', (line) => { + if (!line.trim()) return; + let msg: JsonRpcRequest; + try { + msg = JSON.parse(line) as JsonRpcRequest; + } catch { + return; + } + // Pas d'await : plusieurs demandes peuvent être en attente en parallèle. + void handle(msg).then((res) => { + if (res) process.stdout.write(`${JSON.stringify(res)}\n`); + }); + }); + lines.on('close', () => process.exit(0)); +} + +if (require.main === module) main(); diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index c024ba5178b..a5b126e18ff 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -1,18 +1,22 @@ import { spawn } from 'node:child_process'; +import path from 'node:path'; import readline from 'node:readline'; import type { Mission, MissionStatus, StreamEvent, ToolUseBlock } from '../src/lib/types'; import type { WorkerConfig } from './config'; import { diffSnapshots, fileFromToolUse, snapshotDirty } from './files'; +import { PERMISSION_TOOL, SERVER_NAME } from './permission-mcp'; import { EventSink, type Store } from './store'; const STATUS_POLL_MS = 2000; const KILL_GRACE_MS = 5000; const STDERR_TAIL = 4000; -/** Contexte ajouté au prompt système : l'agent tourne sans humain pour approuver quoi que ce soit. */ -function headlessNote(cwd: string): string { +/** Contexte ajouté au prompt système : l'agent tourne sans terminal, piloté depuis MarIA. */ +function headlessNote(cwd: string, interactive: boolean): string { return [ - 'Tu es exécuté en mode headless par MarIA : aucun humain ne peut approuver une permission pendant la mission.', + interactive + ? 'Tu es exécuté par MarIA sans terminal : toute action non pré-autorisée est soumise à l’utilisateur dans l’interface, qui peut l’autoriser ou la refuser.' + : 'Tu es exécuté en mode headless par MarIA : aucun humain ne peut approuver une permission pendant la mission.', `Ton dossier de travail est ${cwd} ; lance les commandes directement depuis ce dossier, sans \`cd\`, et une commande à la fois (pas de && ni de |) pour qu'elles correspondent aux outils pré-autorisés.`, 'Écris les commandes sous leur forme la plus simple, sans option de changement de dossier (pas de `git -C`, `npm --prefix`, chemins absolus vers le dossier de travail) : par exemple `git status`, `npm test`.', "Si une commande est refusée, ne réessaie pas de variantes : continue avec ce que tu peux faire et liste en fin de réponse les commandes à autoriser.", @@ -20,10 +24,26 @@ function headlessNote(cwd: string): string { ].join('\n'); } -function buildArgs(cfg: WorkerConfig, cwd: string, resumeSessionId: string | null): string[] { +/** Déclare le serveur MCP de permissions (worker/permission-mcp.ts), lancé par Claude Code. */ +function permissionMcpConfig(cfg: WorkerConfig, missionId: string): string { + return JSON.stringify({ + mcpServers: { + [SERVER_NAME]: { + command: process.execPath, + args: [path.join(__dirname, '..', 'node_modules', 'tsx', 'dist', 'cli.mjs'), path.join(__dirname, 'permission-mcp.ts')], + env: { MARIA_MISSION_ID: missionId, MARIA_PERMISSION_TIMEOUT_MS: String(cfg.permissionTimeoutMs) }, + }, + }, + }); +} + +function buildArgs(cfg: WorkerConfig, cwd: string, missionId: string, resumeSessionId: string | null): string[] { // Le prompt passe par stdin : --allowedTools est variadique et avalerait un argument positionnel. const args = ['-p', '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode]; - args.push('--append-system-prompt', headlessNote(cwd)); + args.push('--append-system-prompt', headlessNote(cwd, cfg.interactivePermissions)); + if (cfg.interactivePermissions) { + args.push('--mcp-config', permissionMcpConfig(cfg, missionId), '--permission-prompt-tool', PERMISSION_TOOL); + } if (cfg.allowedTools.length > 0) args.push('--allowedTools', cfg.allowedTools.join(',')); if (cfg.model) args.push('--model', cfg.model); if (resumeSessionId) args.push('--resume', resumeSessionId); @@ -66,9 +86,10 @@ export async function runMission( let stderrTail = ''; let cancelled = false; - const child = spawn(cfg.claudeBin, buildArgs(cfg, cwd, resumeSessionId), { + const child = spawn(cfg.claudeBin, buildArgs(cfg, cwd, mission.id, resumeSessionId), { cwd, - env: process.env, + // Laisse au serveur de permissions le temps d'attendre la décision de l'utilisateur. + env: { ...process.env, MCP_TOOL_TIMEOUT: String(cfg.permissionTimeoutMs + 60_000) }, stdio: ['pipe', 'pipe', 'pipe'], // Groupe de processus dédié pour pouvoir tuer aussi les commandes lancées par l'agent. detached: process.platform !== 'win32', @@ -164,6 +185,7 @@ export async function runMission( error = (result?.is_error && result.result) || stderrTail.trim() || `claude a quitté avec le code ${exit.code}`; } + await store.expirePermissions([mission.id]).catch((err: Error) => console.error(`[maria] ${err.message}`)); sink.info(status === 'completed' ? 'Mission terminée.' : `Mission ${status === 'cancelled' ? 'annulée' : 'en échec'}.`); await sink.flush(); await store.update(mission.id, { diff --git a/apps/maria/worker/store.ts b/apps/maria/worker/store.ts index 9cba62d5da4..e1f8dc81d8b 100644 --- a/apps/maria/worker/store.ts +++ b/apps/maria/worker/store.ts @@ -30,6 +30,17 @@ export class Store { if (error) throw new Error(`registerWorkspaces: ${error.message}`); } + /** Clôt les demandes d'autorisation restées sans réponse (mission terminée ou worker redémarré). */ + async expirePermissions(missionIds: string[]): Promise { + if (missionIds.length === 0) return; + const { error } = await this.db + .from('permission_requests') + .update({ status: 'expired' }) + .in('mission_id', missionIds) + .eq('status', 'pending'); + if (error) throw new Error(`expirePermissions: ${error.message}`); + } + /** Missions restées "running" après un arrêt brutal du worker. */ async failOrphans(names: string[]): Promise { const { data, error } = await this.db @@ -39,6 +50,7 @@ export class Store { .in('status', ['running', 'cancel_requested']) .select('id'); if (error) throw new Error(`failOrphans: ${error.message}`); + await this.expirePermissions(data.map((m) => m.id)); return data.length; } From 602ba1a91a3e7b82d5d7170c31559cb0261a3b39 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:53:24 +0000 Subject: [PATCH 06/15] fix(maria): diagnose a missing permission server instead of silently denying - Read mcp_servers from the init event; if the 'maria' permission server is not connected, post an error in the activity feed explaining that non-allowlisted actions will be denied without a prompt. - Log permission-server errors (startup and Supabase failures) to $TMPDIR/maria-permissions.log, since Claude Code hides MCP stderr. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/lib/types.ts | 1 + apps/maria/worker/permission-mcp.ts | 25 ++++++++++++++++++++++--- apps/maria/worker/runner.ts | 9 +++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/maria/src/lib/types.ts b/apps/maria/src/lib/types.ts index f14973550e9..ae516b7c86d 100644 --- a/apps/maria/src/lib/types.ts +++ b/apps/maria/src/lib/types.ts @@ -71,6 +71,7 @@ export interface StreamEvent { parent_tool_use_id?: string | null; model?: string; tools?: string[]; + mcp_servers?: Array<{ name: string; status: string }>; message?: { content?: ContentBlock[] | string }; // Événement 'result' is_error?: boolean; diff --git a/apps/maria/worker/permission-mcp.ts b/apps/maria/worker/permission-mcp.ts index 66f2bb671fc..1c584c91aa6 100644 --- a/apps/maria/worker/permission-mcp.ts +++ b/apps/maria/worker/permission-mcp.ts @@ -1,7 +1,8 @@ // Serveur MCP (stdio) lancé par Claude Code via --permission-prompt-tool. // Chaque demande d'autorisation devient une ligne maria.permission_requests ; on attend que // l'utilisateur clique « Autoriser » ou « Refuser » dans MarIA, puis on répond à Claude Code. -import { existsSync } from 'node:fs'; +import { appendFileSync, existsSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import readline from 'node:readline'; import { createClient } from '@supabase/supabase-js'; @@ -12,6 +13,17 @@ export const TOOL_NAME = 'approve'; export const PERMISSION_TOOL = `mcp__${SERVER_NAME}__${TOOL_NAME}`; const POLL_MS = 1000; +/** Claude Code masque la sortie d'erreur des serveurs MCP : on journalise aussi dans un fichier. */ +export const LOG_FILE = path.join(os.tmpdir(), 'maria-permissions.log'); + +function logError(message: string): void { + console.error(`[maria-permissions] ${message}`); + try { + appendFileSync(LOG_FILE, `${new Date().toISOString()} ${message}\n`); + } catch { + /* journalisation au mieux */ + } +} export type Decision = 'allowed' | 'denied' | 'expired'; @@ -67,7 +79,7 @@ export function createHandler(backend: PermissionBackend) { } catch (err) { // En cas de panne on refuse : jamais d'autorisation sans décision explicite. decision = 'error'; - console.error(`[maria-permissions] ${(err as Error).message}`); + logError((err as Error).message); } const denyMessages = { denied: 'Action refusée par l’utilisateur dans MarIA.', @@ -124,7 +136,14 @@ function supabaseBackend(): PermissionBackend { } function main(): void { - const handle = createHandler(supabaseBackend()); + let backend: PermissionBackend; + try { + backend = supabaseBackend(); + } catch (err) { + logError(`démarrage impossible : ${(err as Error).message}`); + process.exit(1); + } + const handle = createHandler(backend); const lines = readline.createInterface({ input: process.stdin }); lines.on('line', (line) => { if (!line.trim()) return; diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index a5b126e18ff..ef7b3ed1cdc 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -139,6 +139,15 @@ export async function runMission( sink.info(line, 'warn'); return; } + if (event.type === 'system' && event.subtype === 'init' && cfg.interactivePermissions) { + const server = event.mcp_servers?.find((s) => s.name === SERVER_NAME); + if (server?.status !== 'connected') { + sink.info( + `Le serveur d’autorisations MarIA n’a pas démarré (statut : ${server?.status ?? 'absent'}) : les actions non pré-autorisées seront refusées sans fenêtre. Vérifie le terminal du worker.`, + 'error', + ); + } + } if (event.type === 'system' && event.subtype === 'init' && event.session_id && !sessionId) { sessionId = event.session_id; void store.update(mission.id, { session_id: sessionId }).catch((err: Error) => console.error(`[maria] ${err.message}`)); From 65d1e8ba6722c15af1363d659e4030630ac5974e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 21:23:38 +0000 Subject: [PATCH 07/15] feat(maria): agents panel (Claude Code sub-agents + Ruflo registry) - src/lib/agents.ts rebuilds the mission's agents from the event stream: each Agent/Task tool call opens a sub-agent, messages carrying its parent_tool_use_id count as its actions, and the matching tool_result closes it (done / error; interrupted if the mission ends first). Also tallies calls to Ruflo MCP tools (swarm_init, agent_spawn...). - AgentsPanel shows main agent, sub-agents (type, description, status, action count, last action, nesting) and the Ruflo section. - After a mission that used Ruflo, the worker snapshots the registry via `ruflo agent list --format json` into missions.ruflo_agents (migration 0003). `--all` is avoided: the CLI turns it into a status === 'all' filter that never matches. Command configurable via MARIA_RUFLO_CMD. Verified against a real stream-json capture of a sub-agent run and the real ruflo CLI (spawned agents are listed). Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/app/globals.css | 12 ++- apps/maria/src/components/AgentsPanel.tsx | 84 +++++++++++++++++ apps/maria/src/components/MissionView.tsx | 8 +- apps/maria/src/lib/agents.ts | 89 +++++++++++++++++++ apps/maria/src/lib/types.ts | 10 +++ .../supabase/migrations/0003_ruflo_agents.sql | 3 + apps/maria/worker/config.ts | 8 ++ apps/maria/worker/ruflo.ts | 32 +++++++ apps/maria/worker/runner.ts | 13 +++ 9 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 apps/maria/src/components/AgentsPanel.tsx create mode 100644 apps/maria/src/lib/agents.ts create mode 100644 apps/maria/supabase/migrations/0003_ruflo_agents.sql create mode 100644 apps/maria/worker/ruflo.ts diff --git a/apps/maria/src/app/globals.css b/apps/maria/src/app/globals.css index bb506cd0d20..50abcbb4c33 100644 --- a/apps/maria/src/app/globals.css +++ b/apps/maria/src/app/globals.css @@ -105,7 +105,7 @@ button.danger { background: var(--err); } .mission-view { display: grid; gap: 12px; max-width: 1200px; } .mission-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; } .prompt { margin: 0; white-space: pre-wrap; font-size: 15px; } -.columns { display: grid; grid-template-columns: 1fr 280px; gap: 12px; align-items: start; } +.columns { display: grid; grid-template-columns: 1fr 300px; gap: 12px; align-items: start; } .feed ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } .feed li { padding: 4px 0; border-bottom: 1px dashed var(--border); } @@ -154,3 +154,13 @@ button.danger { background: var(--err); } } .modal-actions { justify-content: flex-end; align-items: center; } .modal-actions .muted { margin-right: auto; } + +.side-col { display: grid; gap: 12px; align-content: start; } +.agent-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; } +.agent-item { display: grid; gap: 2px; padding-bottom: 6px; border-bottom: 1px dashed var(--border); } +.agent-item:last-child { border-bottom: 0; padding-bottom: 0; } +.agent-item.nested { padding-left: 12px; border-left: 2px solid var(--border); } +.agent-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.agent-last { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ruflo { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); display: grid; gap: 6px; } +.ruflo h3 { margin: 0; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } diff --git a/apps/maria/src/components/AgentsPanel.tsx b/apps/maria/src/components/AgentsPanel.tsx new file mode 100644 index 00000000000..c55d83fa2ea --- /dev/null +++ b/apps/maria/src/components/AgentsPanel.tsx @@ -0,0 +1,84 @@ +'use client'; + +import type { AgentsSummary, AgentStatus } from '@/lib/agents'; +import type { RufloAgent } from '@/lib/types'; + +const STATUS: Record = { + running: { label: 'En cours', className: 'running' }, + done: { label: 'Terminé', className: 'completed' }, + error: { label: 'Erreur', className: 'failed' }, + interrupted: { label: 'Interrompu', className: 'cancelled' }, +}; + +interface Props { + summary: AgentsSummary; + rufloAgents: RufloAgent[] | null; + finished: boolean; +} + +/** Qui travaille sur la mission : agent principal, sous-agents Claude Code, et registre Ruflo. */ +export function AgentsPanel({ summary, rufloAgents, finished }: Props) { + const { mainActions, subAgents, rufloCalls } = summary; + const running = subAgents.filter((a) => a.status === 'running').length; + const rufloEntries = Object.entries(rufloCalls); + + return ( +
+

+ Agents + {running > 0 && · {running} en cours} +

+
    +
  • +
    + MarIA + agent principal · {mainActions} action(s) +
    +
  • + {subAgents.map((a) => ( +
  • +
    + {a.type} + {STATUS[a.status].label} +
    + {a.description &&
    {a.description}
    } +
    + {a.actions} action(s){a.lastAction ? ` · ${a.lastAction}` : ''} +
    +
  • + ))} +
+ {subAgents.length === 0 && ( +

{finished ? 'Aucun sous-agent lancé.' : 'Pas encore de sous-agent.'}

+ )} + + {(rufloEntries.length > 0 || rufloAgents) && ( +
+

Ruflo

+ {rufloEntries.length > 0 && ( +

+ Appels : {rufloEntries.map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(', ')} +

+ )} + {rufloAgents === null && rufloEntries.length > 0 && ( +

{finished ? 'Lecture du registre…' : 'Registre lu en fin de mission.'}

+ )} + {rufloAgents && rufloAgents.length === 0 &&

Registre vide.

} + {rufloAgents && rufloAgents.length > 0 && ( +
    + {rufloAgents.map((a) => ( +
  • +
    + {a.agentType} + {a.status} +
    +
    {a.agentId}
    +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/maria/src/components/MissionView.tsx b/apps/maria/src/components/MissionView.tsx index 91487a96e4a..33d2ca06b33 100644 --- a/apps/maria/src/components/MissionView.tsx +++ b/apps/maria/src/components/MissionView.tsx @@ -1,9 +1,11 @@ 'use client'; import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react'; +import { buildAgents } from '@/lib/agents'; import { buildFeed, type FeedItem } from '@/lib/feed'; import { getSupabase, MARIA_SCHEMA } from '@/lib/supabase'; import { FINISHED_STATUSES, type Mission, type MissionEvent } from '@/lib/types'; +import { AgentsPanel } from './AgentsPanel'; import { STATUS_LABEL } from './MissionList'; interface Props { @@ -52,6 +54,7 @@ export function MissionView({ mission, onFollowUp }: Props) { const feed = useMemo(() => buildFeed(events), [events]); const finished = FINISHED_STATUSES.includes(mission.status); + const agents = useMemo(() => buildAgents(events, finished), [events, finished]); useEffect(() => { if (!finished) bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); @@ -89,7 +92,9 @@ export function MissionView({ mission, onFollowUp }: Props) {
-
+
+ +

Fichiers modifiés

{!finished &&

Calculés à la fin de la mission.

} {finished && mission.files_changed.length === 0 &&

Aucun fichier modifié.

} @@ -98,6 +103,7 @@ export function MissionView({ mission, onFollowUp }: Props) {
  • {f}
  • ))} +
    diff --git a/apps/maria/src/lib/agents.ts b/apps/maria/src/lib/agents.ts new file mode 100644 index 00000000000..2fb2a1512b3 --- /dev/null +++ b/apps/maria/src/lib/agents.ts @@ -0,0 +1,89 @@ +import { describeTool } from './feed'; +import type { MissionEvent, ToolResultBlock, ToolUseBlock } from './types'; + +export type AgentStatus = 'running' | 'done' | 'error' | 'interrupted'; + +/** Sous-agent Claude Code lancé via l'outil Agent (ou Task, son ancien nom). */ +export interface SubAgentRun { + id: string; + type: string; + description: string; + status: AgentStatus; + actions: number; + lastAction: string | null; + /** Agent qui l'a lancé (null = agent principal). */ + parentId: string | null; +} + +export interface AgentsSummary { + mainActions: number; + subAgents: SubAgentRun[]; + /** Appels aux outils MCP de Ruflo (swarm_init, agent_spawn…), par nom court. */ + rufloCalls: Record; +} + +const AGENT_TOOLS = new Set(['Agent', 'Task']); +const RUFLO_PREFIX = 'mcp__claude-flow__'; + +function str(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** + * Reconstitue les agents d'une mission à partir des événements stream-json : + * un appel Agent/Task ouvre un sous-agent, ses messages portent parent_tool_use_id, + * et le tool_result correspondant le clôt. + */ +export function buildAgents(events: MissionEvent[], missionFinished: boolean): AgentsSummary { + const runs = new Map(); + const rufloCalls: Record = {}; + let mainActions = 0; + + for (const { payload: e } of events) { + const content = Array.isArray(e.message?.content) ? e.message.content : []; + const owner = e.parent_tool_use_id ? runs.get(e.parent_tool_use_id) : undefined; + + if (e.type === 'assistant') { + for (const block of content) { + if (block.type !== 'tool_use') continue; + const tool = block as ToolUseBlock; + const input = tool.input ?? {}; + + if (owner) { + owner.actions++; + owner.lastAction = `${tool.name} ${describeTool(tool.name, input)}`.trim(); + } else { + mainActions++; + } + if (tool.name.startsWith(RUFLO_PREFIX)) { + const short = tool.name.slice(RUFLO_PREFIX.length); + rufloCalls[short] = (rufloCalls[short] ?? 0) + 1; + } + if (AGENT_TOOLS.has(tool.name)) { + runs.set(tool.id, { + id: tool.id, + type: str(input.subagent_type) || 'general-purpose', + description: str(input.description), + status: 'running', + actions: 0, + lastAction: null, + parentId: owner?.id ?? null, + }); + } + } + } else if (e.type === 'user') { + for (const block of content) { + if (block.type !== 'tool_result') continue; + const res = block as ToolResultBlock; + const run = runs.get(res.tool_use_id); + if (run) run.status = res.is_error ? 'error' : 'done'; + } + } + } + + const subAgents = [...runs.values()]; + if (missionFinished) { + for (const run of subAgents) if (run.status === 'running') run.status = 'interrupted'; + } + return { mainActions, subAgents, rufloCalls }; +} diff --git a/apps/maria/src/lib/types.ts b/apps/maria/src/lib/types.ts index ae516b7c86d..62245f9a68d 100644 --- a/apps/maria/src/lib/types.ts +++ b/apps/maria/src/lib/types.ts @@ -19,12 +19,22 @@ export interface Mission { error: string | null; files_changed: string[]; cost_usd: number | null; + ruflo_agents: RufloAgent[] | null; created_by: string | null; created_at: string; started_at: string | null; finished_at: string | null; } +/** Agent du registre Ruflo, tel que renvoyé par `ruflo agent list --format json`. */ +export interface RufloAgent { + agentId: string; + agentType: string; + status: string; + createdAt?: string; + lastActivityAt?: string; +} + export interface MissionEvent { id: number; mission_id: string; diff --git a/apps/maria/supabase/migrations/0003_ruflo_agents.sql b/apps/maria/supabase/migrations/0003_ruflo_agents.sql new file mode 100644 index 00000000000..a39d689222f --- /dev/null +++ b/apps/maria/supabase/migrations/0003_ruflo_agents.sql @@ -0,0 +1,3 @@ +-- MarIA : instantané du registre d'agents Ruflo (`ruflo agent list`) pris à la fin d'une mission +-- qui a utilisé Ruflo. Null tant qu'aucun instantané n'a été pris. +alter table maria.missions add column ruflo_agents jsonb; diff --git a/apps/maria/worker/config.ts b/apps/maria/worker/config.ts index 292d1f152c2..2c84e5de8db 100644 --- a/apps/maria/worker/config.ts +++ b/apps/maria/worker/config.ts @@ -18,6 +18,8 @@ export interface WorkerConfig { /** Demandes d'autorisation affichées dans MarIA (sinon : refus automatique). */ interactivePermissions: boolean; permissionTimeoutMs: number; + /** Commande Ruflo (ex. `npx -y ruflo@latest`) pour lire le registre d'agents ; null = désactivé. */ + rufloCmd: string[] | null; } function required(name: string): string { @@ -58,6 +60,11 @@ function parseWorkspaces(raw: string): Record { return result; } +function parseCommand(value: string): string[] | null { + const parts = value.trim().split(/\s+/).filter(Boolean); + return parts.length > 0 ? parts : null; +} + export function loadConfig(): WorkerConfig { for (const file of ['.env.local', '.env']) { if (existsSync(file)) process.loadEnvFile(file); @@ -88,5 +95,6 @@ export function loadConfig(): WorkerConfig { pollMs, interactivePermissions: process.env.MARIA_INTERACTIVE_PERMISSIONS?.trim() !== '0', permissionTimeoutMs, + rufloCmd: parseCommand(process.env.MARIA_RUFLO_CMD ?? 'npx -y ruflo@latest'), }; } diff --git a/apps/maria/worker/ruflo.ts b/apps/maria/worker/ruflo.ts new file mode 100644 index 00000000000..39467e6644c --- /dev/null +++ b/apps/maria/worker/ruflo.ts @@ -0,0 +1,32 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { RufloAgent } from '../src/lib/types'; + +const exec = promisify(execFile); +const TIMEOUT_MS = 90_000; + +/** Un appel d'outil laisse-t-il penser que la mission a utilisé Ruflo ? */ +export function touchesRuflo(toolName: string, input: Record): boolean { + if (toolName.startsWith('mcp__claude-flow__')) return true; + return toolName === 'Bash' && typeof input.command === 'string' && /\b(ruflo|claude-flow)\b/.test(input.command); +} + +/** + * Lit le registre d'agents Ruflo du dossier de travail (`agent list --format json`, agents non terminés). + * Pas de `--all` : la CLI le traduit en filtre `status === 'all'`, qui ne renvoie jamais rien. + */ +export async function readRufloAgents(cmd: string[], cwd: string): Promise { + const [bin, ...base] = cmd; + const { stdout } = await exec(bin, [...base, 'agent', 'list', '--format', 'json'], { + cwd, + timeout: TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + env: { ...process.env, npm_config_update_notifier: 'false' }, + }); + // La CLI peut afficher des avertissements avant le JSON : on isole l'objet. + const start = stdout.indexOf('{'); + const end = stdout.lastIndexOf('}'); + if (start === -1 || end <= start) throw new Error('sortie JSON introuvable'); + const parsed = JSON.parse(stdout.slice(start, end + 1)) as { agents?: RufloAgent[] }; + return Array.isArray(parsed.agents) ? parsed.agents : []; +} diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index ef7b3ed1cdc..d1de605b35e 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -5,6 +5,7 @@ import type { Mission, MissionStatus, StreamEvent, ToolUseBlock } from '../src/l import type { WorkerConfig } from './config'; import { diffSnapshots, fileFromToolUse, snapshotDirty } from './files'; import { PERMISSION_TOOL, SERVER_NAME } from './permission-mcp'; +import { readRufloAgents, touchesRuflo } from './ruflo'; import { EventSink, type Store } from './store'; const STATUS_POLL_MS = 2000; @@ -85,6 +86,7 @@ export async function runMission( let resultEvent: StreamEvent | null = null; let stderrTail = ''; let cancelled = false; + let usedRuflo = false; const child = spawn(cfg.claudeBin, buildArgs(cfg, cwd, mission.id, resumeSessionId), { cwd, @@ -158,6 +160,7 @@ export async function runMission( const { name, input } = block as ToolUseBlock; const file = fileFromToolUse(cwd, name, input ?? {}); if (file) toolFiles.add(file); + if (touchesRuflo(name, input ?? {})) usedRuflo = true; } } if (event.type === 'result') resultEvent = event; @@ -206,4 +209,14 @@ export async function runMission( files_changed: files, finished_at: new Date().toISOString(), }); + + // Instantané du registre Ruflo, après coup pour ne pas retarder la fin de mission. + if (usedRuflo && cfg.rufloCmd) { + try { + const agents = await readRufloAgents(cfg.rufloCmd, cwd); + await store.update(mission.id, { ruflo_agents: agents }); + } catch (err) { + console.error(`[maria] registre Ruflo illisible pour ${mission.id} : ${(err as Error).message}`); + } + } } From 6b1e79e3e135667e849080cb2f6a57111582c8d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:04:36 +0000 Subject: [PATCH 08/15] fix(maria): track async sub-agents until Claude Code reports them finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Background sub-agents return 'Async agent launched' immediately; the panel no longer marks them done on that result. The final status now comes from the system task_notification event (completed / failed / stopped), and task_progress keeps the action count up to date. - Feed: one line per finished sub-agent, and intermediate result events (one per turn with async agents) are labelled 'Tour terminé' so only the last one reads as the end of the mission. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/lib/agents.ts | 29 ++++++++++++++++++++++++++++- apps/maria/src/lib/feed.ts | 8 ++++++++ apps/maria/src/lib/types.ts | 6 ++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/apps/maria/src/lib/agents.ts b/apps/maria/src/lib/agents.ts index 2fb2a1512b3..a4769add732 100644 --- a/apps/maria/src/lib/agents.ts +++ b/apps/maria/src/lib/agents.ts @@ -25,6 +25,11 @@ export interface AgentsSummary { const AGENT_TOOLS = new Set(['Agent', 'Task']); const RUFLO_PREFIX = 'mcp__claude-flow__'; +function resultText(block: ToolResultBlock): string { + if (typeof block.content === 'string') return block.content; + return (block.content ?? []).map((c) => c.text ?? '').join(' '); +} + function str(value: unknown): string { return typeof value === 'string' ? value : ''; } @@ -39,7 +44,22 @@ export function buildAgents(events: MissionEvent[], missionFinished: boolean): A const rufloCalls: Record = {}; let mainActions = 0; + // Statut final annoncé par Claude Code (task_notification), qui fait foi même pour les agents asynchrones. + const finalStatus = new Map(); + const background = new Set(); + for (const { payload: e } of events) { + if (e.type === 'system' && e.tool_use_id) { + if (e.subtype === 'task_started' && e.is_backgrounded) background.add(e.tool_use_id); + if (e.subtype === 'task_notification') { + finalStatus.set(e.tool_use_id, e.status === 'completed' ? 'done' : e.status === 'failed' ? 'error' : 'interrupted'); + } + if (e.subtype === 'task_progress') { + const run = runs.get(e.tool_use_id); + if (run && (e.usage?.tool_uses ?? 0) > run.actions) run.actions = e.usage?.tool_uses ?? run.actions; + } + continue; + } const content = Array.isArray(e.message?.content) ? e.message.content : []; const owner = e.parent_tool_use_id ? runs.get(e.parent_tool_use_id) : undefined; @@ -76,11 +96,18 @@ export function buildAgents(events: MissionEvent[], missionFinished: boolean): A if (block.type !== 'tool_result') continue; const res = block as ToolResultBlock; const run = runs.get(res.tool_use_id); - if (run) run.status = res.is_error ? 'error' : 'done'; + if (!run) continue; + if (res.is_error) run.status = 'error'; + // Agent asynchrone : ce résultat signale seulement son lancement. + else if (!background.has(run.id) && !resultText(res).includes('Async agent launched')) run.status = 'done'; } } } + for (const [id, status] of finalStatus) { + const run = runs.get(id); + if (run) run.status = status; + } const subAgents = [...runs.values()]; if (missionFinished) { for (const run of subAgents) if (run.status === 'running') run.status = 'interrupted'; diff --git a/apps/maria/src/lib/feed.ts b/apps/maria/src/lib/feed.ts index 29e9c79ce1e..02b9a1d7956 100644 --- a/apps/maria/src/lib/feed.ts +++ b/apps/maria/src/lib/feed.ts @@ -68,6 +68,11 @@ export function buildFeed(events: MissionEvent[]): FeedItem[] { items.push({ key: `${id}`, kind: 'info', level: e.level ?? 'info', text: e.text ?? '' }); break; case 'system': + if (e.subtype === 'task_notification' && e.tool_use_id) { + const name = agentByToolUse.get(e.tool_use_id) ?? 'sous-agent'; + const label = e.status === 'completed' ? 'a terminé' : e.status === 'failed' ? 'a échoué' : `s’est arrêté (${e.status ?? '?'})`; + items.push({ key: `${id}`, kind: 'info', level: e.status === 'failed' ? 'error' : 'info', text: `Sous-agent ${name} ${label}.` }); + } if (e.subtype === 'init') { items.push({ key: `${id}`, @@ -114,6 +119,9 @@ export function buildFeed(events: MissionEvent[]): FeedItem[] { } } } + // Avec des sous-agents asynchrones, Claude Code émet un résultat par tour : seul le dernier clôt la mission. + const results = items.filter((i): i is Extract => i.kind === 'done'); + for (const r of results.slice(0, -1)) r.text = r.text.replace(/^(Terminé|Échec)/, 'Tour terminé'); return items; } diff --git a/apps/maria/src/lib/types.ts b/apps/maria/src/lib/types.ts index 62245f9a68d..8586cf2e19a 100644 --- a/apps/maria/src/lib/types.ts +++ b/apps/maria/src/lib/types.ts @@ -90,6 +90,12 @@ export interface StreamEvent { duration_ms?: number; num_turns?: number; permission_denials?: Array<{ tool_name: string; tool_input?: Record }>; + // Événements system task_* (sous-agents) + tool_use_id?: string; + status?: string; + is_backgrounded?: boolean; + last_tool_name?: string; + usage?: { tool_uses?: number }; // Événement 'maria' (émis par le worker) level?: 'info' | 'warn' | 'error'; text?: string; From a0417fb0b0b0768c4d7e818d5e0e1a397916ad34 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 11:37:41 +0000 Subject: [PATCH 09/15] feat(maria): isolated missions in git worktrees, run in parallel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New per-mission option "Branche isolée": the worker creates a worktree on branch maria/- from the workspace HEAD, links shared untracked state (.claude-flow, .swarm, node_modules) and copies local config (.mcp.json, .claude, CLAUDE.md) when they are not versioned. - Worktree missions run in parallel (MARIA_MAX_PARALLEL per workspace, default 3); in-place missions and merges stay exclusive on the main folder. claim_next_mission now takes in-place and worktree eligible workspaces separately (migration 0004). - At the end of a mission, the agent's changes are committed on the branch (linked/copied entries excluded). "Continuer" reuses the parent's worktree so the Claude session can resume in the same directory. - Branch panel in the mission view: merge into the workspace's current branch (refused if it has uncommitted changes; aborted cleanly on conflict) or discard (worktree and branch removed), via the request_worktree_action RPC; the worker executes and reports errors. Verified on real git (create, commit, merge, dirty refusal, conflict abort, discard), the SQL on Postgres 16, and two parallel missions through the runner with a fake claude. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/app/globals.css | 5 + apps/maria/src/components/BranchPanel.tsx | 59 +++++++ apps/maria/src/components/MissionForm.tsx | 25 ++- apps/maria/src/components/MissionList.tsx | 3 +- apps/maria/src/components/MissionView.tsx | 4 +- apps/maria/src/lib/types.ts | 7 + .../supabase/migrations/0004_worktrees.sql | 62 ++++++++ apps/maria/worker/actions.ts | 34 ++++ apps/maria/worker/config.ts | 17 ++ apps/maria/worker/index.ts | 58 +++++-- apps/maria/worker/runner.ts | 98 +++++++++++- apps/maria/worker/store.ts | 35 ++++- apps/maria/worker/worktree.ts | 146 ++++++++++++++++++ 13 files changed, 526 insertions(+), 27 deletions(-) create mode 100644 apps/maria/src/components/BranchPanel.tsx create mode 100644 apps/maria/supabase/migrations/0004_worktrees.sql create mode 100644 apps/maria/worker/actions.ts create mode 100644 apps/maria/worker/worktree.ts diff --git a/apps/maria/src/app/globals.css b/apps/maria/src/app/globals.css index 50abcbb4c33..1745681f230 100644 --- a/apps/maria/src/app/globals.css +++ b/apps/maria/src/app/globals.css @@ -164,3 +164,8 @@ button.danger { background: var(--err); } .agent-last { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ruflo { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); display: grid; gap: 6px; } .ruflo h3 { margin: 0; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); } + +.check { display: flex; align-items: flex-start; gap: 8px; margin-top: 8px; color: var(--muted); cursor: pointer; } +.check input { width: auto; margin-top: 2px; } +.branch { display: grid; gap: 8px; } +.branch code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/apps/maria/src/components/BranchPanel.tsx b/apps/maria/src/components/BranchPanel.tsx new file mode 100644 index 00000000000..4f7b7345661 --- /dev/null +++ b/apps/maria/src/components/BranchPanel.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useState } from 'react'; +import { getSupabase } from '@/lib/supabase'; +import type { Mission } from '@/lib/types'; + +const STATE_LABEL = { + active: { label: 'À valider', className: 'running' }, + merged: { label: 'Fusionnée', className: 'completed' }, + discarded: { label: 'Abandonnée', className: 'cancelled' }, +} as const; + +/** Branche git isolée de la mission : fusion dans le dossier principal ou abandon. */ +export function BranchPanel({ mission, finished }: { mission: Mission; finished: boolean }) { + const [error, setError] = useState(null); + if (!mission.branch || !mission.worktree_state) return null; + + const state = STATE_LABEL[mission.worktree_state]; + const pending = mission.worktree_action; + const canAct = finished && mission.worktree_state === 'active' && !pending; + + async function request(action: 'merge' | 'discard') { + const question = + action === 'merge' + ? `Fusionner ${mission.branch} dans la branche actuelle du dossier « ${mission.workspace} » ?` + : `Abandonner ${mission.branch} ? Le worktree et la branche seront supprimés, et les modifications perdues.`; + if (!window.confirm(question)) return; + setError(null); + const { error: err } = await getSupabase().rpc('request_worktree_action', { p_id: mission.id, p_action: action }); + if (err) setError(err.message); + } + + return ( +
    +

    Branche

    +
    + {mission.branch} + {state.label} +
    + {mission.worktree_path && mission.worktree_state === 'active' && ( +

    Worktree : {mission.worktree_path}

    + )} + {!finished && mission.worktree_state === 'active' && ( +

    Les modifications seront commitées sur la branche à la fin de la mission.

    + )} + {pending &&

    {pending === 'merge' ? 'Fusion en cours…' : 'Suppression en cours…'}

    } + {mission.worktree_error &&

    {mission.worktree_error}

    } + {error &&

    {error}

    } + {canAct && ( +
    + + +
    + )} +
    + ); +} diff --git a/apps/maria/src/components/MissionForm.tsx b/apps/maria/src/components/MissionForm.tsx index f97f2ffa8f8..c620d93bc1c 100644 --- a/apps/maria/src/components/MissionForm.tsx +++ b/apps/maria/src/components/MissionForm.tsx @@ -18,9 +18,28 @@ interface Props { export function MissionForm({ workspaces, onCreated }: Props) { const [prompt, setPrompt] = useState(''); const [workspace, setWorkspace] = useState(''); + const [useWorktree, setUseWorktree] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + // Préférence mémorisée dans le navigateur (simple confort, sans conséquence si le stockage est indisponible). + useEffect(() => { + try { + setUseWorktree(localStorage.getItem('maria.useWorktree') === '1'); + } catch { + /* stockage indisponible */ + } + }, []); + + function toggleWorktree(value: boolean) { + setUseWorktree(value); + try { + localStorage.setItem('maria.useWorktree', value ? '1' : '0'); + } catch { + /* stockage indisponible */ + } + } + useEffect(() => { if (!workspace && workspaces.length > 0) setWorkspace(workspaces[0].name); }, [workspaces, workspace]); @@ -34,7 +53,7 @@ export function MissionForm({ workspaces, onCreated }: Props) { setError(null); const { data, error: err } = await getSupabase() .from('missions') - .insert({ prompt: prompt.trim(), workspace }) + .insert({ prompt: prompt.trim(), workspace, use_worktree: useWorktree }) .select() .single(); setBusy(false); @@ -70,6 +89,10 @@ export function MissionForm({ workspaces, onCreated }: Props) { {busy ? '…' : 'Lancer'} + {current && !isOnline(current) && (

    Worker hors ligne : la mission attendra son redémarrage.

    )} diff --git a/apps/maria/src/components/MissionList.tsx b/apps/maria/src/components/MissionList.tsx index 6acd25aeccf..ea923f32a34 100644 --- a/apps/maria/src/components/MissionList.tsx +++ b/apps/maria/src/components/MissionList.tsx @@ -27,7 +27,8 @@ export function MissionList({ missions, selectedId, onSelect }: Props) { {STATUS_LABEL[m.status]} {m.prompt} - {m.workspace} · {new Date(m.created_at).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })} + {m.workspace} + {m.branch ? ` · ⑂ ${m.worktree_state === 'active' ? 'branche à valider' : m.worktree_state === 'merged' ? 'fusionnée' : 'abandonnée'}` : ''} · {new Date(m.created_at).toLocaleString('fr-FR', { dateStyle: 'short', timeStyle: 'short' })} diff --git a/apps/maria/src/components/MissionView.tsx b/apps/maria/src/components/MissionView.tsx index 33d2ca06b33..e1814c2c2f6 100644 --- a/apps/maria/src/components/MissionView.tsx +++ b/apps/maria/src/components/MissionView.tsx @@ -6,6 +6,7 @@ import { buildFeed, type FeedItem } from '@/lib/feed'; import { getSupabase, MARIA_SCHEMA } from '@/lib/supabase'; import { FINISHED_STATUSES, type Mission, type MissionEvent } from '@/lib/types'; import { AgentsPanel } from './AgentsPanel'; +import { BranchPanel } from './BranchPanel'; import { STATUS_LABEL } from './MissionList'; interface Props { @@ -93,6 +94,7 @@ export function MissionView({ mission, onFollowUp }: Props) {
    +

    Fichiers modifiés

    @@ -149,7 +151,7 @@ function FollowUpForm({ parent, onCreated }: { parent: Mission; onCreated: (m: M setBusy(true); const { data, error: err } = await getSupabase() .from('missions') - .insert({ prompt: prompt.trim(), workspace: parent.workspace, parent_id: parent.id }) + .insert({ prompt: prompt.trim(), workspace: parent.workspace, parent_id: parent.id, use_worktree: parent.use_worktree }) .select() .single(); setBusy(false); diff --git a/apps/maria/src/lib/types.ts b/apps/maria/src/lib/types.ts index 8586cf2e19a..2a30071007a 100644 --- a/apps/maria/src/lib/types.ts +++ b/apps/maria/src/lib/types.ts @@ -20,6 +20,13 @@ export interface Mission { files_changed: string[]; cost_usd: number | null; ruflo_agents: RufloAgent[] | null; + use_worktree: boolean; + branch: string | null; + worktree_path: string | null; + base_commit: string | null; + worktree_state: 'active' | 'merged' | 'discarded' | null; + worktree_action: 'merge' | 'discard' | null; + worktree_error: string | null; created_by: string | null; created_at: string; started_at: string | null; diff --git a/apps/maria/supabase/migrations/0004_worktrees.sql b/apps/maria/supabase/migrations/0004_worktrees.sql new file mode 100644 index 00000000000..f703ff3a169 --- /dev/null +++ b/apps/maria/supabase/migrations/0004_worktrees.sql @@ -0,0 +1,62 @@ +-- MarIA : missions isolées dans un worktree git (une branche par mission), pour pouvoir +-- lancer plusieurs missions en parallèle sur le même dossier. Le résultat reste sur la +-- branche jusqu'à ce que l'utilisateur la fusionne ou l'abandonne depuis l'interface. + +alter table maria.missions + add column use_worktree boolean not null default false, + add column branch text, + add column worktree_path text, + add column base_commit text, + add column worktree_state text check (worktree_state in ('active', 'merged', 'discarded')), + add column worktree_action text check (worktree_action in ('merge', 'discard')), + add column worktree_error text; + +grant insert (use_worktree) on maria.missions to authenticated; + +-- Le front demande une fusion ou un abandon ; le worker exécute puis remet worktree_action à null. +create function maria.request_worktree_action(p_id uuid, p_action text) +returns void +language sql +security definer +set search_path = '' +as $$ + update maria.missions + set worktree_action = p_action, worktree_error = null + where id = p_id + and p_action in ('merge', 'discard') + and worktree_state = 'active' + and status in ('completed', 'failed', 'cancelled') + and maria.is_member(); +$$; + +revoke execute on function maria.request_worktree_action(uuid, text) from public, anon; +grant execute on function maria.request_worktree_action(uuid, text) to authenticated; + +-- Réservation : les missions « sur place » seulement pour les dossiers libres, les missions +-- en worktree pour les dossiers qui ont encore de la capacité. +drop function maria.claim_next_mission(text[]); + +create function maria.claim_next_mission(p_inplace text[], p_worktree text[]) +returns setof maria.missions +language sql +security definer +set search_path = '' +as $$ + update maria.missions + set status = 'running', started_at = now() + where id = ( + select id from maria.missions + where status = 'queued' + and ( + (use_worktree and workspace = any (p_worktree)) + or (not use_worktree and workspace = any (p_inplace)) + ) + order by created_at + for update skip locked + limit 1 + ) + returning *; +$$; + +revoke execute on function maria.claim_next_mission(text[], text[]) from public, anon, authenticated; +grant execute on function maria.claim_next_mission(text[], text[]) to service_role; diff --git a/apps/maria/worker/actions.ts b/apps/maria/worker/actions.ts new file mode 100644 index 00000000000..39e6e69580e --- /dev/null +++ b/apps/maria/worker/actions.ts @@ -0,0 +1,34 @@ +// Fusion ou abandon d'une branche de mission, demandés depuis l'interface. +import type { Mission } from '../src/lib/types'; +import type { Store } from './store'; +import { mergeWorktree, removeWorktree } from './worktree'; + +export async function runWorktreeAction(mission: Mission, workspaceDir: string, store: Store): Promise { + const { worktree_path: wtPath, branch, worktree_action: action } = mission; + if (!wtPath || !branch || !action) return; + + if (await store.worktreeBusy(wtPath)) { + await store.updateWorktree(wtPath, { + worktree_action: null, + worktree_error: 'Une mission tourne encore dans cette branche : attends sa fin ou annule-la.', + }); + return; + } + + try { + if (action === 'merge') { + await mergeWorktree(workspaceDir, { path: wtPath, branch }, `MarIA: fusion de ${branch}`); + } else { + await removeWorktree(workspaceDir, { path: wtPath, branch }); + } + await store.updateWorktree(wtPath, { + worktree_state: action === 'merge' ? 'merged' : 'discarded', + worktree_action: null, + worktree_error: null, + }); + console.log(`[maria] branche ${branch} ${action === 'merge' ? 'fusionnée' : 'abandonnée'}`); + } catch (err) { + await store.updateWorktree(wtPath, { worktree_action: null, worktree_error: (err as Error).message }); + console.error(`[maria] ${branch} : ${(err as Error).message}`); + } +} diff --git a/apps/maria/worker/config.ts b/apps/maria/worker/config.ts index 2c84e5de8db..9e0d3a99c0c 100644 --- a/apps/maria/worker/config.ts +++ b/apps/maria/worker/config.ts @@ -1,4 +1,5 @@ import { existsSync, statSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; const PERMISSION_MODES = ['acceptEdits', 'auto', 'bypassPermissions', 'dontAsk', 'plan'] as const; @@ -20,6 +21,14 @@ export interface WorkerConfig { permissionTimeoutMs: number; /** Commande Ruflo (ex. `npx -y ruflo@latest`) pour lire le registre d'agents ; null = désactivé. */ rufloCmd: string[] | null; + /** Dossier où créer les worktrees des missions isolées. */ + worktreeRoot: string; + /** Éléments non versionnés reliés (symlink) dans chaque worktree. */ + worktreeLinks: string[]; + /** Éléments non versionnés copiés dans chaque worktree. */ + worktreeCopies: string[]; + /** Nombre maximal de missions en worktree simultanées par dossier. */ + maxParallel: number; } function required(name: string): string { @@ -83,6 +92,10 @@ export function loadConfig(): WorkerConfig { throw new Error('MARIA_PERMISSION_TIMEOUT_MS doit être >= 10000'); } + const maxParallel = Number(process.env.MARIA_MAX_PARALLEL ?? 3); + if (!Number.isInteger(maxParallel) || maxParallel < 1) throw new Error('MARIA_MAX_PARALLEL doit être un entier >= 1'); + const worktreeRoot = path.resolve(process.env.MARIA_WORKTREE_DIR?.trim() || path.join(os.homedir(), '.maria', 'worktrees')); + return { supabaseUrl: required('SUPABASE_URL'), serviceRoleKey: required('SUPABASE_SERVICE_ROLE_KEY'), @@ -96,5 +109,9 @@ export function loadConfig(): WorkerConfig { interactivePermissions: process.env.MARIA_INTERACTIVE_PERMISSIONS?.trim() !== '0', permissionTimeoutMs, rufloCmd: parseCommand(process.env.MARIA_RUFLO_CMD ?? 'npx -y ruflo@latest'), + worktreeRoot, + worktreeLinks: list(process.env.MARIA_WORKTREE_LINKS ?? '.claude-flow,.swarm,node_modules'), + worktreeCopies: list(process.env.MARIA_WORKTREE_COPY ?? '.mcp.json,.claude,CLAUDE.md'), + maxParallel, }; } diff --git a/apps/maria/worker/index.ts b/apps/maria/worker/index.ts index 39859f89a46..e583a5f7694 100644 --- a/apps/maria/worker/index.ts +++ b/apps/maria/worker/index.ts @@ -1,8 +1,10 @@ // Worker MarIA : récupère les missions en attente dans Supabase et les exécute avec Claude Code. // Lancement : npm run worker (depuis apps/maria, avec .env.local rempli). +import { runWorktreeAction } from './actions'; import { loadConfig } from './config'; import { runMission } from './runner'; import { Store } from './store'; +import type { Mission } from '../src/lib/types'; const HEARTBEAT_MS = 30_000; @@ -21,31 +23,57 @@ async function main(): Promise { console.log(`[maria] worker prêt — dossiers : ${names.map((n) => `${n} → ${cfg.workspaces[n]}`).join(', ')}`); console.log(`[maria] permissions : mode ${cfg.permissionMode}, outils autorisés : ${cfg.allowedTools.join(', ') || '(aucun)'}`); console.log(`[maria] autres actions : ${cfg.interactivePermissions ? `demandées dans MarIA (délai ${Math.round(cfg.permissionTimeoutMs / 1000)} s)` : 'refusées automatiquement'}`); + console.log(`[maria] branches isolées : jusqu'à ${cfg.maxParallel} en parallèle par dossier, dans ${cfg.worktreeRoot}`); - // Une mission à la fois par dossier : deux agents dans le même dossier se marcheraient dessus. - const running = new Map>(); + // Dans le dossier principal : une seule mission « sur place » (ou fusion) à la fois, sinon elles se marcheraient dessus. + // Les missions en worktree ont chacune leur copie : jusqu'à maxParallel en même temps par dossier. + const mainBusy = new Set(); + const worktreeCount = new Map(); + const running = new Set>(); const shutdown = new AbortController(); let polling = false; + const track = (task: Promise, release: () => void) => { + const p = task.finally(() => { + release(); + running.delete(p); + }); + running.add(p); + }; + + const startMission = (mission: Mission) => { + console.log(`[maria] mission ${mission.id} → ${mission.workspace}${mission.use_worktree ? ' (branche isolée)' : ''}`); + if (mission.use_worktree) worktreeCount.set(mission.workspace, (worktreeCount.get(mission.workspace) ?? 0) + 1); + else mainBusy.add(mission.workspace); + const task = runMission(mission, cfg, store, shutdown.signal).catch(async (err: Error) => { + console.error(`[maria] mission ${mission.id} : ${err.message}`); + await store + .update(mission.id, { status: 'failed', error: err.message, finished_at: new Date().toISOString() }) + .catch(() => undefined); + }); + track(task, () => { + if (mission.use_worktree) worktreeCount.set(mission.workspace, (worktreeCount.get(mission.workspace) ?? 1) - 1); + else mainBusy.delete(mission.workspace); + }); + }; + const poll = async () => { if (polling || shutdown.signal.aborted) return; polling = true; try { + // Fusions et abandons demandés : ils touchent le dossier principal, donc exclusifs avec les missions sur place. + for (const mission of await store.pendingWorktreeActions(names.filter((n) => !mainBusy.has(n)))) { + if (mainBusy.has(mission.workspace)) continue; + mainBusy.add(mission.workspace); + track(runWorktreeAction(mission, cfg.workspaces[mission.workspace], store), () => mainBusy.delete(mission.workspace)); + } for (;;) { - const idle = names.filter((n) => !running.has(n)); - if (idle.length === 0) break; - const mission = await store.claimNext(idle); + const inplace = names.filter((n) => !mainBusy.has(n)); + const worktree = names.filter((n) => (worktreeCount.get(n) ?? 0) < cfg.maxParallel); + if (inplace.length === 0 && worktree.length === 0) break; + const mission = await store.claimNext(inplace, worktree); if (!mission) break; - console.log(`[maria] mission ${mission.id} → ${mission.workspace}`); - const run = runMission(mission, cfg, store, shutdown.signal) - .catch(async (err: Error) => { - console.error(`[maria] mission ${mission.id} : ${err.message}`); - await store - .update(mission.id, { status: 'failed', error: err.message, finished_at: new Date().toISOString() }) - .catch(() => undefined); - }) - .finally(() => running.delete(mission.workspace)); - running.set(mission.workspace, run); + startMission(mission); } } catch (err) { console.error(`[maria] ${(err as Error).message}`); diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index d1de605b35e..4c7f5d0e881 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; import path from 'node:path'; import readline from 'node:readline'; import type { Mission, MissionStatus, StreamEvent, ToolUseBlock } from '../src/lib/types'; @@ -7,13 +8,14 @@ import { diffSnapshots, fileFromToolUse, snapshotDirty } from './files'; import { PERMISSION_TOOL, SERVER_NAME } from './permission-mcp'; import { readRufloAgents, touchesRuflo } from './ruflo'; import { EventSink, type Store } from './store'; +import { commitWorktree, createWorktree, isGitRepo, worktreeCwd, type WorktreeInfo } from './worktree'; const STATUS_POLL_MS = 2000; const KILL_GRACE_MS = 5000; const STDERR_TAIL = 4000; /** Contexte ajouté au prompt système : l'agent tourne sans terminal, piloté depuis MarIA. */ -function headlessNote(cwd: string, interactive: boolean): string { +function headlessNote(cwd: string, interactive: boolean, branch: string | null): string { return [ interactive ? 'Tu es exécuté par MarIA sans terminal : toute action non pré-autorisée est soumise à l’utilisateur dans l’interface, qui peut l’autoriser ou la refuser.' @@ -22,6 +24,11 @@ function headlessNote(cwd: string, interactive: boolean): string { 'Écris les commandes sous leur forme la plus simple, sans option de changement de dossier (pas de `git -C`, `npm --prefix`, chemins absolus vers le dossier de travail) : par exemple `git status`, `npm test`.', "Si une commande est refusée, ne réessaie pas de variantes : continue avec ce que tu peux faire et liste en fin de réponse les commandes à autoriser.", "Si tu as besoin d'une décision de l'utilisateur, termine ta réponse par une question claire : il pourra répondre via « Continuer ».", + ...(branch + ? [ + `Tu travailles dans un worktree git isolé, sur la branche dédiée ${branch}. Ne change pas de branche, ne fais ni git push ni git commit : MarIA commitera tes modifications à la fin, puis l'utilisateur décidera de fusionner ou non.`, + ] + : []), ].join('\n'); } @@ -38,10 +45,10 @@ function permissionMcpConfig(cfg: WorkerConfig, missionId: string): string { }); } -function buildArgs(cfg: WorkerConfig, cwd: string, missionId: string, resumeSessionId: string | null): string[] { +function buildArgs(cfg: WorkerConfig, cwd: string, missionId: string, resumeSessionId: string | null, branch: string | null): string[] { // Le prompt passe par stdin : --allowedTools est variadique et avalerait un argument positionnel. const args = ['-p', '--output-format', 'stream-json', '--verbose', '--permission-mode', cfg.permissionMode]; - args.push('--append-system-prompt', headlessNote(cwd, cfg.interactivePermissions)); + args.push('--append-system-prompt', headlessNote(cwd, cfg.interactivePermissions, branch)); if (cfg.interactivePermissions) { args.push('--mcp-config', permissionMcpConfig(cfg, missionId), '--permission-prompt-tool', PERMISSION_TOOL); } @@ -51,7 +58,13 @@ function buildArgs(cfg: WorkerConfig, cwd: string, missionId: string, resumeSess return args; } -async function resolveResume(store: Store, mission: Mission, sink: EventSink): Promise { +async function resolveResume( + store: Store, + mission: Mission, + sink: EventSink, + cfg: WorkerConfig, + cwd: string, +): Promise { if (!mission.parent_id) return null; const parent = await store.getMission(mission.parent_id); if (!parent?.session_id) { @@ -62,9 +75,62 @@ async function resolveResume(store: Store, mission: Mission, sink: EventSink): P sink.info('La mission parente est dans un autre dossier : reprise impossible.', 'warn'); return null; } + // Claude Code range ses sessions par dossier : la reprise n'est possible que dans le même dossier. + const workspaceDir = cfg.workspaces[mission.workspace]; + const parentCwd = parent.worktree_path ? await worktreeCwd(workspaceDir, parent.worktree_path).catch(() => null) : workspaceDir; + if (parentCwd !== cwd) { + sink.info('La mission parente a tourné dans un autre dossier (branche fusionnée ou abandonnée) : nouvelle conversation.', 'warn'); + return null; + } return parent.session_id; } +/** Choisit le dossier de travail : le workspace, ou un worktree dédié (nouveau, ou celui de la mission parente). */ +async function prepareWorkdir( + mission: Mission, + cfg: WorkerConfig, + store: Store, + sink: EventSink, +): Promise<{ cwd: string; worktree: WorktreeInfo | null }> { + const workspaceDir = cfg.workspaces[mission.workspace]; + if (!mission.use_worktree) return { cwd: workspaceDir, worktree: null }; + + const parent = mission.parent_id ? await store.getMission(mission.parent_id) : null; + if (parent?.worktree_state === 'active' && parent.worktree_path && parent.branch && existsSync(parent.worktree_path)) { + const worktree: WorktreeInfo = { + path: parent.worktree_path, + cwd: await worktreeCwd(workspaceDir, parent.worktree_path), + branch: parent.branch, + baseCommit: parent.base_commit ?? '', + }; + await store.update(mission.id, { + branch: worktree.branch, + worktree_path: worktree.path, + base_commit: parent.base_commit, + worktree_state: 'active', + }); + sink.info(`Suite dans la branche ${worktree.branch}`); + return { cwd: worktree.cwd, worktree }; + } + + if (!(await isGitRepo(workspaceDir))) { + throw new Error(`« ${mission.workspace} » n’est pas un dépôt git : impossible d’isoler la mission dans une branche (lance « git init » dans le dossier, ou décoche « Branche isolée »).`); + } + const worktree = await createWorktree(workspaceDir, mission.workspace, mission.id, mission.prompt, { + root: cfg.worktreeRoot, + links: cfg.worktreeLinks, + copies: cfg.worktreeCopies, + }); + await store.update(mission.id, { + branch: worktree.branch, + worktree_path: worktree.path, + base_commit: worktree.baseCommit, + worktree_state: 'active', + }); + sink.info(`Branche isolée ${worktree.branch} créée (${worktree.path})`); + return { cwd: worktree.cwd, worktree }; +} + /** * Exécute une mission avec `claude -p` dans son dossier de travail et diffuse chaque * événement stream-json vers Supabase. `signal` permet au worker d'interrompre la mission à l'arrêt. @@ -75,11 +141,19 @@ export async function runMission( store: Store, signal: AbortSignal, ): Promise { - const cwd = cfg.workspaces[mission.workspace]; const sink = new EventSink(store, mission.id); sink.info(`Mission démarrée dans « ${mission.workspace} »`); - const resumeSessionId = await resolveResume(store, mission, sink); + let prepared: { cwd: string; worktree: WorktreeInfo | null }; + try { + prepared = await prepareWorkdir(mission, cfg, store, sink); + } catch (err) { + sink.info((err as Error).message, 'error'); + await sink.flush(); + throw err; + } + const { cwd, worktree } = prepared; + const resumeSessionId = await resolveResume(store, mission, sink, cfg, cwd); const before = await snapshotDirty(cwd).catch(() => null); const toolFiles = new Set(); let sessionId: string | null = null; @@ -88,7 +162,7 @@ export async function runMission( let cancelled = false; let usedRuflo = false; - const child = spawn(cfg.claudeBin, buildArgs(cfg, cwd, mission.id, resumeSessionId), { + const child = spawn(cfg.claudeBin, buildArgs(cfg, cwd, mission.id, resumeSessionId, worktree?.branch ?? null), { cwd, // Laisse au serveur de permissions le temps d'attendre la décision de l'utilisateur. env: { ...process.env, MCP_TOOL_TIMEOUT: String(cfg.permissionTimeoutMs + 60_000) }, @@ -197,6 +271,16 @@ export async function runMission( error = (result?.is_error && result.result) || stderrTail.trim() || `claude a quitté avec le code ${exit.code}`; } + if (worktree && !exit.error) { + const title = mission.prompt.split('\n')[0].slice(0, 72); + try { + const sha = await commitWorktree(cwd, `MarIA: ${title}\n\nMission ${mission.id}`, [...cfg.worktreeLinks, ...cfg.worktreeCopies]); + sink.info(sha ? `Modifications commitées sur ${worktree.branch} (${sha.slice(0, 7)}).` : `Aucune modification à commiter sur ${worktree.branch}.`); + } catch (err) { + sink.info(`Commit sur ${worktree.branch} impossible : ${(err as Error).message}`, 'error'); + } + } + await store.expirePermissions([mission.id]).catch((err: Error) => console.error(`[maria] ${err.message}`)); sink.info(status === 'completed' ? 'Mission terminée.' : `Mission ${status === 'cancelled' ? 'annulée' : 'en échec'}.`); await sink.flush(); diff --git a/apps/maria/worker/store.ts b/apps/maria/worker/store.ts index e1f8dc81d8b..3d608f7f6f4 100644 --- a/apps/maria/worker/store.ts +++ b/apps/maria/worker/store.ts @@ -54,13 +54,44 @@ export class Store { return data.length; } - async claimNext(workspaces: string[]): Promise { - const { data, error } = await this.db.rpc('claim_next_mission', { p_workspaces: workspaces }); + /** Réserve la prochaine mission : « sur place » pour les dossiers libres, en worktree pour ceux qui ont de la capacité. */ + async claimNext(inplace: string[], worktree: string[]): Promise { + const { data, error } = await this.db.rpc('claim_next_mission', { p_inplace: inplace, p_worktree: worktree }); if (error) throw new Error(`claimNext: ${error.message}`); const rows = data as Mission[] | null; return rows?.[0] ?? null; } + /** Missions dont l'utilisateur a demandé la fusion ou l'abandon de la branche. */ + async pendingWorktreeActions(workspaces: string[]): Promise { + const { data, error } = await this.db + .from('missions') + .select('*') + .in('workspace', workspaces) + .eq('worktree_state', 'active') + .not('worktree_action', 'is', null) + .order('created_at'); + if (error) throw new Error(`pendingWorktreeActions: ${error.message}`); + return data as Mission[]; + } + + /** Une mission tourne-t-elle encore dans ce worktree ? */ + async worktreeBusy(worktreePath: string): Promise { + const { count, error } = await this.db + .from('missions') + .select('id', { count: 'exact', head: true }) + .eq('worktree_path', worktreePath) + .in('status', ['queued', 'running', 'cancel_requested']); + if (error) throw new Error(`worktreeBusy: ${error.message}`); + return (count ?? 0) > 0; + } + + /** Met à jour toutes les missions qui partagent un worktree (une mission et ses suites). */ + async updateWorktree(worktreePath: string, patch: Partial): Promise { + const { error } = await this.db.from('missions').update(patch).eq('worktree_path', worktreePath); + if (error) throw new Error(`updateWorktree: ${error.message}`); + } + async getMission(id: string): Promise { const { data, error } = await this.db.from('missions').select('*').eq('id', id).maybeSingle(); if (error) throw new Error(`getMission: ${error.message}`); diff --git a/apps/maria/worker/worktree.ts b/apps/maria/worker/worktree.ts new file mode 100644 index 00000000000..acde8aa96fd --- /dev/null +++ b/apps/maria/worker/worktree.ts @@ -0,0 +1,146 @@ +// Worktrees git : chaque mission isolée travaille sur sa propre branche, dans un dossier à part. +import { execFile } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, symlinkSync } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); + +export interface WorktreeInfo { + /** Racine du worktree (copie du dépôt sur la branche de la mission). */ + path: string; + /** Dossier de travail de l'agent dans le worktree (le workspace peut être un sous-dossier du dépôt). */ + cwd: string; + branch: string; + baseCommit: string; +} + +export interface WorktreeOptions { + /** Dossier racine où créer les worktrees. */ + root: string; + /** Éléments non versionnés du workspace à relier par lien symbolique (état Ruflo partagé, node_modules…). */ + links: string[]; + /** Éléments non versionnés à copier (configuration Claude/Ruflo locale). */ + copies: string[]; +} + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await exec('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }); + return stdout.trim(); +} + +/** Message d'erreur git lisible (stderr plutôt que la commande complète). */ +function gitError(err: unknown): string { + const e = err as { stderr?: string; stdout?: string; message?: string }; + const text = e.stderr?.trim() || e.stdout?.trim() || e.message || String(err); + return text.trim().split('\n').slice(-3).join(' '); +} + +export async function isGitRepo(dir: string): Promise { + try { + return (await git(dir, ['rev-parse', '--is-inside-work-tree'])) === 'true'; + } catch { + return false; + } +} + +function slugify(text: string): string { + return ( + text + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .slice(0, 30) + .replace(/^-+|-+$/g, '') || 'mission' + ); +} + +/** Crée le worktree et la branche `maria/-` à partir du commit courant du workspace. */ +export async function createWorktree( + workspaceDir: string, + workspaceName: string, + missionId: string, + prompt: string, + opts: WorktreeOptions, +): Promise { + const top = await git(workspaceDir, ['rev-parse', '--show-toplevel']); + const rel = path.relative(top, workspaceDir); + const baseCommit = await git(workspaceDir, ['rev-parse', 'HEAD']); + const short = missionId.slice(0, 8); + const branch = `maria/${short}-${slugify(prompt)}`; + const wtPath = path.join(opts.root, workspaceName, short); + + mkdirSync(path.dirname(wtPath), { recursive: true }); + try { + await git(top, ['worktree', 'add', '-b', branch, wtPath, baseCommit]); + } catch (err) { + throw new Error(`Création du worktree impossible : ${gitError(err)}`); + } + + const cwd = path.join(wtPath, rel); + for (const name of opts.copies) { + const src = path.join(workspaceDir, name); + const dst = path.join(cwd, name); + if (existsSync(src) && !existsSync(dst)) cpSync(src, dst, { recursive: true }); + } + for (const name of opts.links) { + const src = path.join(workspaceDir, name); + const dst = path.join(cwd, name); + if (existsSync(src) && !existsSync(dst)) symlinkSync(src, dst); + } + return { path: wtPath, cwd, branch, baseCommit }; +} + +/** + * Commite tout le travail de l'agent sur la branche du worktree, sauf les éléments reliés ou copiés. + * Renvoie le hash du commit, ou null s'il n'y avait rien à commiter. + */ +export async function commitWorktree(cwd: string, message: string, excluded: string[]): Promise { + const relToTop = await git(cwd, ['rev-parse', '--show-prefix']); + const excludes = excluded.map((name) => `:(top,exclude)${path.posix.join(relToTop, name)}`); + await git(cwd, ['add', '-A', '--', ':/', ...excludes]); + const staged = await git(cwd, ['diff', '--cached', '--name-only']); + if (!staged) return null; + + // Identité de secours si git n'est pas configuré sur la machine. + const hasIdentity = await git(cwd, ['config', 'user.email']).then(Boolean, () => false); + const identity = hasIdentity ? [] : ['-c', 'user.name=MarIA', '-c', 'user.email=maria@localhost']; + await git(cwd, [...identity, 'commit', '--no-verify', '-q', '-m', message]); + return git(cwd, ['rev-parse', 'HEAD']); +} + +/** Supprime le worktree et sa branche. */ +export async function removeWorktree(workspaceDir: string, wt: Pick): Promise { + const top = await git(workspaceDir, ['rev-parse', '--show-toplevel']); + if (existsSync(wt.path)) await git(top, ['worktree', 'remove', '--force', wt.path]); + else await git(top, ['worktree', 'prune']); + await git(top, ['branch', '-D', wt.branch]).catch(() => undefined); +} + +/** + * Fusionne la branche de la mission dans la branche actuelle du workspace. + * Refuse si le workspace a des modifications non commitées ; annule proprement en cas de conflit. + */ +export async function mergeWorktree(workspaceDir: string, wt: Pick, message: string): Promise { + const top = await git(workspaceDir, ['rev-parse', '--show-toplevel']); + const dirty = await git(top, ['status', '--porcelain', '--untracked-files=no']); + if (dirty) { + throw new Error('Le dossier principal a des modifications non commitées : commite-les ou mets-les de côté (git stash) avant de fusionner.'); + } + const hasIdentity = await git(top, ['config', 'user.email']).then(Boolean, () => false); + const identity = hasIdentity ? [] : ['-c', 'user.name=MarIA', '-c', 'user.email=maria@localhost']; + try { + await git(top, [...identity, 'merge', '--no-ff', '--no-edit', '-m', message, wt.branch]); + } catch (err) { + await git(top, ['merge', '--abort']).catch(() => undefined); + throw new Error(`Fusion impossible (conflit ?) : ${gitError(err)}. Résous-la à la main ou abandonne la branche.`); + } + await removeWorktree(workspaceDir, wt); +} + +/** Dossier de travail correspondant à `workspaceDir` dans un worktree existant. */ +export async function worktreeCwd(workspaceDir: string, wtPath: string): Promise { + const top = await git(workspaceDir, ['rev-parse', '--show-toplevel']); + return path.join(wtPath, path.relative(top, workspaceDir)); +} From fc798dc4eec46377ca7f0cbda75a5acfc9531db4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:43:10 +0000 Subject: [PATCH 10/15] fix(maria): disable AskUserQuestion in headless missions With no terminal the interactive question is lost ('The user did not answer'). Disallow the tool so the agent asks in plain text at the end of its answer; the user replies with 'Continuer'. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/worker/runner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index 4c7f5d0e881..1b8f519a083 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -52,6 +52,8 @@ function buildArgs(cfg: WorkerConfig, cwd: string, missionId: string, resumeSess if (cfg.interactivePermissions) { args.push('--mcp-config', permissionMcpConfig(cfg, missionId), '--permission-prompt-tool', PERMISSION_TOOL); } + // Pas de terminal pour répondre aux questions interactives : l'agent pose ses questions en texte (réponse via « Continuer »). + args.push('--disallowedTools', 'AskUserQuestion'); if (cfg.allowedTools.length > 0) args.push('--allowedTools', cfg.allowedTools.join(',')); if (cfg.model) args.push('--model', cfg.model); if (resumeSessionId) args.push('--resume', resumeSessionId); From cffab1754e5cc0ac7d4af2be9f9dfa96b4d3f0a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:46:27 +0000 Subject: [PATCH 11/15] feat(maria): answer the agent's questions in a pop-up (AskUserQuestion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AskUserQuestion already reaches MarIA's permission prompt tool; it used to be approved without answers, so the agent got "did not answer". - The permission MCP server now returns the user's answers as updatedInput.answers ({ question: answer }), the format Claude Code expects; skipping or timing out tells the agent to proceed with reasonable choices and say so. - Migration 0005: permission_requests.response + answer_question() RPC (members only, AskUserQuestion requests only, first answer wins). - Front: the pop-up shows each question with its options (single or multiple choice), an "Autre" free-text answer, "Répondre" and "Ne pas répondre". - AskUserQuestion is only disabled when interactive permissions are off; the agent is told to use it when it needs a decision. Verified with the real claude CLI (answered, skipped, and a regular permission request) and the SQL on Postgres 16. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/app/globals.css | 9 ++ .../maria/src/components/PermissionPrompt.tsx | 37 ++++++++- apps/maria/src/components/QuestionForm.tsx | 83 +++++++++++++++++++ apps/maria/src/lib/types.ts | 9 ++ .../supabase/migrations/0005_questions.sql | 26 ++++++ apps/maria/worker/permission-mcp.ts | 27 ++++-- apps/maria/worker/runner.ts | 9 +- 7 files changed, 187 insertions(+), 13 deletions(-) create mode 100644 apps/maria/src/components/QuestionForm.tsx create mode 100644 apps/maria/supabase/migrations/0005_questions.sql diff --git a/apps/maria/src/app/globals.css b/apps/maria/src/app/globals.css index 1745681f230..ad667f71f77 100644 --- a/apps/maria/src/app/globals.css +++ b/apps/maria/src/app/globals.css @@ -169,3 +169,12 @@ button.danger { background: var(--err); } .check input { width: auto; margin-top: 2px; } .branch { display: grid; gap: 8px; } .branch code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.modal h2.question-title { color: var(--accent); } +.questions { display: grid; gap: 14px; } +.question { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; margin: 0; display: grid; gap: 6px; } +.question legend { padding: 0 4px; font-weight: 600; } +.question .badge { justify-self: start; } +.option { display: flex; align-items: center; gap: 8px; cursor: pointer; } +.option input[type='radio'], .option input[type='checkbox'] { width: auto; flex: none; } +.option .other { flex: 1; padding: 4px 8px; } diff --git a/apps/maria/src/components/PermissionPrompt.tsx b/apps/maria/src/components/PermissionPrompt.tsx index 8bf4e91b2bf..d034213f742 100644 --- a/apps/maria/src/components/PermissionPrompt.tsx +++ b/apps/maria/src/components/PermissionPrompt.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from 'react'; import { describeTool } from '@/lib/feed'; import { getSupabase, MARIA_SCHEMA } from '@/lib/supabase'; -import type { Mission, PermissionRequest } from '@/lib/types'; +import type { AgentQuestion, Mission, PermissionRequest } from '@/lib/types'; +import { QuestionForm } from './QuestionForm'; const BASE_TITLE = 'MarIA'; @@ -46,13 +47,27 @@ export function PermissionPrompt({ missions }: { missions: Mission[] }) { // Signale une demande en attente dans l'onglet, même quand MarIA n'est pas au premier plan. useEffect(() => { - document.title = pending.length > 0 ? `(${pending.length}) Autorisation requise — ${BASE_TITLE}` : BASE_TITLE; + document.title = pending.length > 0 ? `(${pending.length}) Action requise — ${BASE_TITLE}` : BASE_TITLE; }, [pending.length]); const current = pending[0]; if (!current) return null; const mission = missions.find((m) => m.id === current.mission_id); + const questions = + current.tool_name === 'AskUserQuestion' && Array.isArray(current.input.questions) + ? (current.input.questions as AgentQuestion[]) + : null; + + async function answer(answers: Record) { + setBusy(true); + setError(null); + const { error: err } = await getSupabase().rpc('answer_question', { p_id: current.id, p_answers: answers }); + setBusy(false); + if (err) setError(err.message); + else setPending((prev) => prev.filter((r) => r.id !== current.id)); + } + async function decide(allow: boolean) { setBusy(true); setError(null); @@ -62,6 +77,24 @@ export function PermissionPrompt({ missions }: { missions: Mission[] }) { else setPending((prev) => prev.filter((r) => r.id !== current.id)); } + if (questions) { + return ( +
    +
    +

    L’agent a une question

    + {mission && ( +

    + Mission « {mission.prompt.length > 80 ? `${mission.prompt.slice(0, 80)}…` : mission.prompt} » · {mission.workspace} +

    + )} + decide(false)} /> + {error &&

    {error}

    } + {pending.length > 1 &&

    +{pending.length - 1} autre(s) demande(s) en attente

    } +
    +
    + ); + } + return (
    diff --git a/apps/maria/src/components/QuestionForm.tsx b/apps/maria/src/components/QuestionForm.tsx new file mode 100644 index 00000000000..9bc2388dea1 --- /dev/null +++ b/apps/maria/src/components/QuestionForm.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useState, type FormEvent } from 'react'; +import type { AgentQuestion } from '@/lib/types'; + +const OTHER = '__other__'; + +interface Props { + questions: AgentQuestion[]; + busy: boolean; + onAnswer: (answers: Record) => void; + onSkip: () => void; +} + +/** Questions de l'agent (AskUserQuestion) : choix proposés + réponse libre « Autre ». */ +export function QuestionForm({ questions, busy, onAnswer, onSkip }: Props) { + const [selected, setSelected] = useState>({}); + const [other, setOther] = useState>({}); + + const toggle = (qi: number, value: string, multi: boolean) => + setSelected((prev) => { + const current = prev[qi] ?? []; + const next = multi ? (current.includes(value) ? current.filter((v) => v !== value) : [...current, value]) : [value]; + return { ...prev, [qi]: next }; + }); + + const answerFor = (qi: number): string => { + const values = (selected[qi] ?? []).map((v) => (v === OTHER ? (other[qi] ?? '').trim() : v)).filter(Boolean); + return values.join(', '); + }; + const complete = questions.every((_, qi) => answerFor(qi) !== ''); + + function submit(e: FormEvent) { + e.preventDefault(); + if (!complete) return; + onAnswer(Object.fromEntries(questions.map((q, qi) => [q.question, answerFor(qi)]))); + } + + return ( +
    + {questions.map((q, qi) => { + const multi = !!q.multiSelect; + const type = multi ? 'checkbox' : 'radio'; + const isChecked = (v: string) => (selected[qi] ?? []).includes(v); + return ( +
    + {q.header && {q.header}} + {q.question} + {multi &&

    Plusieurs choix possibles.

    } + {(q.options ?? []).map((o) => ( + + ))} + +
    + ); + })} +
    + + +
    +
    + ); +} diff --git a/apps/maria/src/lib/types.ts b/apps/maria/src/lib/types.ts index 2a30071007a..9eedec506b3 100644 --- a/apps/maria/src/lib/types.ts +++ b/apps/maria/src/lib/types.ts @@ -114,5 +114,14 @@ export interface PermissionRequest { tool_name: string; input: Record; status: 'pending' | 'allowed' | 'denied' | 'expired'; + response: Record | null; created_at: string; } + +/** Entrée de l'outil AskUserQuestion de Claude Code. */ +export interface AgentQuestion { + question: string; + header?: string; + multiSelect?: boolean; + options?: Array<{ label: string; description?: string }>; +} diff --git a/apps/maria/supabase/migrations/0005_questions.sql b/apps/maria/supabase/migrations/0005_questions.sql new file mode 100644 index 00000000000..c5e260ae29b --- /dev/null +++ b/apps/maria/supabase/migrations/0005_questions.sql @@ -0,0 +1,26 @@ +-- MarIA : questions posées par l'agent (outil AskUserQuestion), affichées dans une fenêtre. +-- Elles passent par le même circuit que les autorisations (maria.permission_requests) ; +-- la réponse de l'utilisateur est stockée dans `response` ({ "": "" }). + +alter table maria.permission_requests add column response jsonb; + +create function maria.answer_question(p_id uuid, p_answers jsonb) +returns void +language sql +security definer +set search_path = '' +as $$ + update maria.permission_requests + set status = 'allowed', + response = p_answers, + decided_by = auth.uid(), + decided_at = now() + where id = p_id + and status = 'pending' + and tool_name = 'AskUserQuestion' + and jsonb_typeof(p_answers) = 'object' + and maria.is_member(); +$$; + +revoke execute on function maria.answer_question(uuid, jsonb) from public, anon; +grant execute on function maria.answer_question(uuid, jsonb) to authenticated; diff --git a/apps/maria/worker/permission-mcp.ts b/apps/maria/worker/permission-mcp.ts index 1c584c91aa6..63a0598910d 100644 --- a/apps/maria/worker/permission-mcp.ts +++ b/apps/maria/worker/permission-mcp.ts @@ -27,11 +27,19 @@ function logError(message: string): void { export type Decision = 'allowed' | 'denied' | 'expired'; +export interface PermissionAnswer { + decision: Decision; + /** Réponses aux questions d'AskUserQuestion : { "": "" }. */ + response?: Record | null; +} + export interface PermissionBackend { /** Crée la demande et renvoie la décision de l'utilisateur (ou 'expired' après le délai). */ - ask(toolName: string, input: Record): Promise; + ask(toolName: string, input: Record): Promise; } +const QUESTION_TOOL = 'AskUserQuestion'; + interface JsonRpcRequest { jsonrpc: '2.0'; id?: number | string; @@ -73,22 +81,25 @@ export function createHandler(backend: PermissionBackend) { case 'tools/call': { const args = (msg.params?.arguments ?? {}) as { tool_name?: string; input?: Record }; const input = args.input ?? {}; + const toolName = args.tool_name ?? 'inconnu'; let decision: Decision | 'error'; + let response: Record | null = null; try { - decision = await backend.ask(args.tool_name ?? 'inconnu', input); + ({ decision, response = null } = await backend.ask(toolName, input)); } catch (err) { // En cas de panne on refuse : jamais d'autorisation sans décision explicite. decision = 'error'; logError((err as Error).message); } + const isQuestion = toolName === QUESTION_TOOL; const denyMessages = { - denied: 'Action refusée par l’utilisateur dans MarIA.', - expired: 'Pas de réponse de l’utilisateur dans le délai imparti : action refusée.', + denied: isQuestion ? 'L’utilisateur a préféré ne pas répondre à ces questions : continue avec des choix raisonnables, et signale-les.' : 'Action refusée par l’utilisateur dans MarIA.', + expired: isQuestion ? 'Pas de réponse de l’utilisateur dans le délai imparti : continue avec des choix raisonnables, et signale-les.' : 'Pas de réponse de l’utilisateur dans le délai imparti : action refusée.', error: 'Impossible de soumettre la demande à MarIA (erreur technique) : action refusée.', }; const answer = decision === 'allowed' - ? { behavior: 'allow', updatedInput: input } + ? { behavior: 'allow', updatedInput: isQuestion ? { ...input, answers: response ?? {} } : input } : { behavior: 'deny', message: denyMessages[decision] }; return reply({ content: [{ type: 'text', text: JSON.stringify(answer) }] }); } @@ -125,12 +136,12 @@ function supabaseBackend(): PermissionBackend { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, POLL_MS)); - const { data: row, error: err } = await db.from('permission_requests').select('status').eq('id', data.id).single(); + const { data: row, error: err } = await db.from('permission_requests').select('status, response').eq('id', data.id).single(); if (err) continue; // erreur réseau passagère : on réessaie - if (row.status !== 'pending') return row.status as Decision; + if (row.status !== 'pending') return { decision: row.status as Decision, response: row.response as Record | null }; } await db.from('permission_requests').update({ status: 'expired' }).eq('id', data.id).eq('status', 'pending'); - return 'expired'; + return { decision: 'expired' }; }, }; } diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index 1b8f519a083..15213b918cc 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -23,7 +23,9 @@ function headlessNote(cwd: string, interactive: boolean, branch: string | null): `Ton dossier de travail est ${cwd} ; lance les commandes directement depuis ce dossier, sans \`cd\`, et une commande à la fois (pas de && ni de |) pour qu'elles correspondent aux outils pré-autorisés.`, 'Écris les commandes sous leur forme la plus simple, sans option de changement de dossier (pas de `git -C`, `npm --prefix`, chemins absolus vers le dossier de travail) : par exemple `git status`, `npm test`.', "Si une commande est refusée, ne réessaie pas de variantes : continue avec ce que tu peux faire et liste en fin de réponse les commandes à autoriser.", - "Si tu as besoin d'une décision de l'utilisateur, termine ta réponse par une question claire : il pourra répondre via « Continuer ».", + interactive + ? "Si tu as besoin d'une décision de l'utilisateur pour avancer, utilise l'outil AskUserQuestion : la question s'affiche dans MarIA et sa réponse te revient directement." + : "Si tu as besoin d'une décision de l'utilisateur, termine ta réponse par une question claire : il pourra répondre via « Continuer ».", ...(branch ? [ `Tu travailles dans un worktree git isolé, sur la branche dédiée ${branch}. Ne change pas de branche, ne fais ni git push ni git commit : MarIA commitera tes modifications à la fin, puis l'utilisateur décidera de fusionner ou non.`, @@ -52,8 +54,9 @@ function buildArgs(cfg: WorkerConfig, cwd: string, missionId: string, resumeSess if (cfg.interactivePermissions) { args.push('--mcp-config', permissionMcpConfig(cfg, missionId), '--permission-prompt-tool', PERMISSION_TOOL); } - // Pas de terminal pour répondre aux questions interactives : l'agent pose ses questions en texte (réponse via « Continuer »). - args.push('--disallowedTools', 'AskUserQuestion'); + // Les questions (AskUserQuestion) s'affichent dans MarIA via le serveur de permissions ; sans lui, personne ne + // peut y répondre : l'agent doit alors poser ses questions en texte (réponse via « Continuer »). + if (!cfg.interactivePermissions) args.push('--disallowedTools', 'AskUserQuestion'); if (cfg.allowedTools.length > 0) args.push('--allowedTools', cfg.allowedTools.join(',')); if (cfg.model) args.push('--model', cfg.model); if (resumeSessionId) args.push('--resume', resumeSessionId); From 0784c4e53c6d5da34e6384343d977272ed8def37 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:25:16 +0000 Subject: [PATCH 12/15] fix(maria): feed cleanup and sub-agent relay guidance - Show 'Session Claude Code ouverte' only once (Claude Code re-emits init on every resume when async sub-agents report back). - Only announce task_notification for sub-agents; background commands (npm test, build) emit it too and showed up as 'Sous-agent sous-agent'. - Cost is cumulative: display it only on the final result line. - Headless note: sub-agents cannot message each other, so the agent is told to run them synchronously, relay each result itself, and not poll with ScheduleWakeup/ListAgents. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/lib/feed.ts | 25 +++++++++++++++++-------- apps/maria/worker/runner.ts | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/maria/src/lib/feed.ts b/apps/maria/src/lib/feed.ts index 02b9a1d7956..f59f87b3171 100644 --- a/apps/maria/src/lib/feed.ts +++ b/apps/maria/src/lib/feed.ts @@ -57,6 +57,8 @@ function resultText(block: ToolResultBlock): string { */ export function buildFeed(events: MissionEvent[]): FeedItem[] { const agentByToolUse = new Map(); + const resultEvents: Array<{ item: Extract; event: StreamEvent }> = []; + let sessionShown = false; const items: FeedItem[] = []; for (const { id, payload: e } of events) { @@ -68,12 +70,15 @@ export function buildFeed(events: MissionEvent[]): FeedItem[] { items.push({ key: `${id}`, kind: 'info', level: e.level ?? 'info', text: e.text ?? '' }); break; case 'system': - if (e.subtype === 'task_notification' && e.tool_use_id) { - const name = agentByToolUse.get(e.tool_use_id) ?? 'sous-agent'; + // Les commandes lancées en arrière-plan émettent aussi task_notification : on ne garde que les sous-agents. + if (e.subtype === 'task_notification' && e.tool_use_id && agentByToolUse.has(e.tool_use_id)) { + const name = agentByToolUse.get(e.tool_use_id); const label = e.status === 'completed' ? 'a terminé' : e.status === 'failed' ? 'a échoué' : `s’est arrêté (${e.status ?? '?'})`; items.push({ key: `${id}`, kind: 'info', level: e.status === 'failed' ? 'error' : 'info', text: `Sous-agent ${name} ${label}.` }); } - if (e.subtype === 'init') { + // Avec des sous-agents asynchrones, Claude Code renvoie un init à chaque reprise : on n'affiche que le premier. + if (e.subtype === 'init' && !sessionShown) { + sessionShown = true; items.push({ key: `${id}`, kind: 'info', @@ -114,21 +119,25 @@ export function buildFeed(events: MissionEvent[]): FeedItem[] { text: `${denials.length} action(s) refusée(s) (par toi, ou sans réponse dans le délai). Pour ne plus avoir à les valider, ajoute-les à MARIA_ALLOWED_TOOLS :\n${list.join('\n')}`, }); } - items.push({ key: `${id}`, kind: 'done', isError: !!e.is_error, text: summarizeResult(e) }); + const item: Extract = { key: `${id}`, kind: 'done', isError: !!e.is_error, text: summarizeResult(e, true) }; + resultEvents.push({ item, event: e }); + items.push(item); break; } } } // Avec des sous-agents asynchrones, Claude Code émet un résultat par tour : seul le dernier clôt la mission. - const results = items.filter((i): i is Extract => i.kind === 'done'); - for (const r of results.slice(0, -1)) r.text = r.text.replace(/^(Terminé|Échec)/, 'Tour terminé'); + // Le coût est cumulé depuis le début : on ne l'affiche que sur la ligne finale. + for (const { item, event } of resultEvents.slice(0, -1)) { + item.text = summarizeResult(event, false).replace(/^(Terminé|Échec)/, 'Tour terminé'); + } return items; } -function summarizeResult(e: StreamEvent): string { +function summarizeResult(e: StreamEvent, withCost: boolean): string { const parts: string[] = []; if (e.duration_ms != null) parts.push(`${Math.round(e.duration_ms / 1000)} s`); if (e.num_turns != null) parts.push(`${e.num_turns} tours`); - if (e.total_cost_usd != null) parts.push(`$${e.total_cost_usd.toFixed(4)}`); + if (withCost && e.total_cost_usd != null) parts.push(`$${e.total_cost_usd.toFixed(4)}`); return `${e.is_error ? 'Échec' : 'Terminé'}${parts.length ? ` — ${parts.join(' · ')}` : ''}`; } diff --git a/apps/maria/worker/runner.ts b/apps/maria/worker/runner.ts index 15213b918cc..c32634d1651 100644 --- a/apps/maria/worker/runner.ts +++ b/apps/maria/worker/runner.ts @@ -23,6 +23,7 @@ function headlessNote(cwd: string, interactive: boolean, branch: string | null): `Ton dossier de travail est ${cwd} ; lance les commandes directement depuis ce dossier, sans \`cd\`, et une commande à la fois (pas de && ni de |) pour qu'elles correspondent aux outils pré-autorisés.`, 'Écris les commandes sous leur forme la plus simple, sans option de changement de dossier (pas de `git -C`, `npm --prefix`, chemins absolus vers le dossier de travail) : par exemple `git status`, `npm test`.', "Si une commande est refusée, ne réessaie pas de variantes : continue avec ce que tu peux faire et liste en fin de réponse les commandes à autoriser.", + "Si tu délègues à des sous-agents (outil Agent) : ils n'ont pas d'outil pour se parler entre eux. Lance-les en mode synchrone, l'un après l'autre quand ils dépendent les uns des autres (en parallèle seulement s'ils sont indépendants), et transmets toi-même le résultat de chacun dans la consigne du suivant. Ne leur demande pas d'attendre un message d'un autre agent, et ne vérifie pas leur état en boucle (pas de ScheduleWakeup ni de ListAgents répétés).", interactive ? "Si tu as besoin d'une décision de l'utilisateur pour avancer, utilise l'outil AskUserQuestion : la question s'affiche dans MarIA et sa réponse te revient directement." : "Si tu as besoin d'une décision de l'utilisateur, termine ta réponse par une question claire : il pourra répondre via « Continuer ».", From 063e41ef04cf1a7969b290ec2ab129df7ac110b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:47:33 +0000 Subject: [PATCH 13/15] feat(maria): restrict missions to @-mentioned agents chained in order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "@architect @coder Ajoute…" limits the mission to those sub-agents: the main agent is told to delegate each step in order, synchronously, relaying each result to the next. - A PreToolUse hook (worker/agent-guard.ts, passed via --settings) denies any other sub-agent type and background launches. - The worker publishes the agents available in each folder (.claude/agents, user agents, built-ins) to maria.workspaces.agents (migration 0006); the mission form autocompletes @ mentions and previews the chain. Co-Authored-By: RuFlo Claude-Session: https://claude.ai/code/session_01EYLzTmzezSkXAERkr41FBp --- apps/maria/src/app/globals.css | 10 ++ apps/maria/src/components/AgentPrompt.tsx | 121 ++++++++++++++++++ apps/maria/src/components/MissionForm.tsx | 18 +-- apps/maria/src/lib/mentions.ts | 30 +++++ apps/maria/src/lib/types.ts | 3 + .../migrations/0006_workspace_agents.sql | 3 + apps/maria/worker/agent-guard.ts | 44 +++++++ apps/maria/worker/agents.ts | 53 ++++++++ apps/maria/worker/index.ts | 7 +- apps/maria/worker/runner.ts | 36 +++++- apps/maria/worker/store.ts | 8 +- 11 files changed, 315 insertions(+), 18 deletions(-) create mode 100644 apps/maria/src/components/AgentPrompt.tsx create mode 100644 apps/maria/src/lib/mentions.ts create mode 100644 apps/maria/supabase/migrations/0006_workspace_agents.sql create mode 100644 apps/maria/worker/agent-guard.ts create mode 100644 apps/maria/worker/agents.ts diff --git a/apps/maria/src/app/globals.css b/apps/maria/src/app/globals.css index ad667f71f77..412482b4244 100644 --- a/apps/maria/src/app/globals.css +++ b/apps/maria/src/app/globals.css @@ -178,3 +178,13 @@ button.danger { background: var(--err); } .option { display: flex; align-items: center; gap: 8px; cursor: pointer; } .option input[type='radio'], .option input[type='checkbox'] { width: auto; flex: none; } .option .other { flex: 1; padding: 4px 8px; } + +.agent-prompt { position: relative; display: grid; gap: 4px; } +.mention-menu { + position: absolute; z-index: 20; left: 0; right: 0; top: 100%; margin: 4px 0 0; padding: 4px; + list-style: none; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .25); max-height: 280px; overflow-y: auto; +} +.mention-menu li { padding: 6px 8px; border-radius: 6px; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mention-menu li.active { background: var(--border); } +.chain { margin: 0; color: var(--accent); } diff --git a/apps/maria/src/components/AgentPrompt.tsx b/apps/maria/src/components/AgentPrompt.tsx new file mode 100644 index 00000000000..71d9f7989e2 --- /dev/null +++ b/apps/maria/src/components/AgentPrompt.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useMemo, useRef, useState, type KeyboardEvent } from 'react'; +import { mentionAt, parseMentions, type AgentInfo } from '@/lib/mentions'; + +const MAX_SUGGESTIONS = 8; + +interface Props { + value: string; + onChange: (value: string) => void; + agents: AgentInfo[]; + placeholder: string; + rows: number; + onSubmit: () => void; +} + +/** Zone de saisie de mission avec autocomplétion des mentions « @agent » et aperçu de la chaîne. */ +export function AgentPrompt({ value, onChange, agents, placeholder, rows, onSubmit }: Props) { + const ref = useRef(null); + const [mention, setMention] = useState<{ start: number; query: string } | null>(null); + const [active, setActive] = useState(0); + + const suggestions = useMemo(() => { + if (!mention) return []; + const q = mention.query.toLowerCase(); + const starts = agents.filter((a) => a.name.toLowerCase().startsWith(q)); + const contains = agents.filter((a) => !a.name.toLowerCase().startsWith(q) && a.name.toLowerCase().includes(q)); + return [...starts, ...contains].slice(0, MAX_SUGGESTIONS); + }, [agents, mention]); + + const chain = useMemo(() => parseMentions(value, agents.map((a) => a.name)), [value, agents]); + + function refresh(text: string, caret: number | null) { + setMention(caret == null ? null : mentionAt(text, caret)); + setActive(0); + } + + function pick(agent: AgentInfo) { + const el = ref.current; + if (!el || !mention) return; + const caret = el.selectionStart; + const insert = `@${agent.name} `; + const next = value.slice(0, mention.start) + insert + value.slice(caret); + onChange(next); + setMention(null); + const pos = mention.start + insert.length; + requestAnimationFrame(() => { + el.focus(); + el.setSelectionRange(pos, pos); + }); + } + + function onKeyDown(e: KeyboardEvent) { + if (suggestions.length > 0) { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + const step = e.key === 'ArrowDown' ? 1 : -1; + setActive((i) => (i + step + suggestions.length) % suggestions.length); + return; + } + if ((e.key === 'Enter' && !e.metaKey && !e.ctrlKey) || e.key === 'Tab') { + e.preventDefault(); + pick(suggestions[active]); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setMention(null); + return; + } + } + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) onSubmit(); + } + + return ( +
    +