Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
797b4bb
fix(maria): require Node.js 22+ (native WebSocket needed by supabase-js)
claude Oct 1, 2026
4807029
feat(maria): headless guidance for the agent and surface permission d…
claude Oct 1, 2026
06d2095
chore(maria): log effective permission mode and allowed tools at work…
claude Oct 1, 2026
c78e69f
fix(maria): steer the headless agent away from git -C / --prefix forms
claude Oct 1, 2026
20a6c36
feat(maria): interactive permission prompts (allow / deny window)
claude Oct 1, 2026
602ba1a
fix(maria): diagnose a missing permission server instead of silently …
claude Oct 1, 2026
65d1e8b
feat(maria): agents panel (Claude Code sub-agents + Ruflo registry)
claude Oct 1, 2026
7199690
Merge branch 'ruvnet:main' into claude/zen-hawking-ioda4g
dr-phil-2004 Oct 1, 2026
6b1e79e
fix(maria): track async sub-agents until Claude Code reports them fin…
claude Oct 1, 2026
57ade42
Merge remote-tracking branch 'origin/claude/zen-hawking-ioda4g' into …
claude Oct 1, 2026
a0417fb
feat(maria): isolated missions in git worktrees, run in parallel
claude Oct 2, 2026
fc798dc
fix(maria): disable AskUserQuestion in headless missions
claude Oct 2, 2026
cffab17
feat(maria): answer the agent's questions in a pop-up (AskUserQuestion)
claude Oct 2, 2026
0784c4e
fix(maria): feed cleanup and sub-agent relay guidance
claude Oct 2, 2026
063e41e
feat(maria): restrict missions to @-mentioned agents chained in order
claude Oct 2, 2026
462ba3e
fix(maria): list every agent in the @ mention menu
claude Oct 2, 2026
bd1a840
feat(maria): Ruflo memory view
claude Oct 2, 2026
b527e56
Merge remote-tracking branch 'origin/main' into claude/zen-hawking-io…
claude Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/maria/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 *)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Les commandes préautorisées évitent la validation interactive

L'exemple préautorise Bash(npx *) et Bash(node *). Ces commandes peuvent exécuter du code arbitraire sans afficher la fenêtre de permission annoncée.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

# 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).
Expand Down
1 change: 1 addition & 0 deletions apps/maria/.nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
2 changes: 1 addition & 1 deletion apps/maria/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20.12"
"node": ">=22"
}
}
83 changes: 81 additions & 2 deletions apps/maria/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand All @@ -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); }
Expand All @@ -128,3 +128,82 @@ 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; }

.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); }

.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; }

.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; }

.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); }

.tabs { display: flex; gap: 4px; margin-left: 12px; }
.tab { background: none; color: var(--muted); padding: 4px 10px; border-radius: 8px; }
.tab.active { background: var(--border); color: var(--text); }
.memory { display: grid; gap: 12px; align-content: start; }
.memory-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.memory-head h2 { margin: 0 8px 0 0; font-size: 18px; }
.memory-head select { width: auto; }
.memory-head input { flex: 1; min-width: 180px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip { background: none; border: 1px solid var(--border); color: var(--muted); padding: 2px 10px; border-radius: 999px; font-size: 12px; }
.chip.active { border-color: var(--accent); color: var(--accent); }
.memory-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.memory-item { display: grid; gap: 6px; }
.memory-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.memory-key { word-break: break-all; }
.memory-content { margin: 0; white-space: pre-wrap; word-break: break-word; background: var(--code-bg); border-radius: 8px; padding: 8px 10px; font-size: 12px; }
.tag { color: var(--accent); }
@media (max-width: 720px) { .tabs { margin-left: 0; } }
122 changes: 122 additions & 0 deletions apps/maria/src/components/AgentPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

import { useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { mentionAt, mentionName, parseMentions, type AgentInfo } from '@/lib/mentions';

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<HTMLTextAreaElement>(null);
const [mention, setMention] = useState<{ start: number; query: string } | null>(null);
const [active, setActive] = useState(0);

const suggestions = useMemo(() => {
if (!mention) return [];
// Tous les agents correspondants (le menu défile) : ceux qui commencent par la saisie d'abord.
const q = mention.query.toLowerCase();
const key = (a: AgentInfo) => mentionName(a.name).toLowerCase();
const starts = agents.filter((a) => key(a).startsWith(q));
const contains = agents.filter((a) => !key(a).startsWith(q) && (key(a).includes(q) || a.description.toLowerCase().includes(q)));
return [...starts, ...contains];
}, [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 = `@${mentionName(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<HTMLTextAreaElement>) {
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 (
<div className="agent-prompt">
<textarea
ref={ref}
placeholder={placeholder}
value={value}
rows={rows}
onChange={(e) => {
onChange(e.target.value);
refresh(e.target.value, e.target.selectionStart);
}}
onKeyDown={onKeyDown}
onClick={(e) => refresh(e.currentTarget.value, e.currentTarget.selectionStart)}
onBlur={() => setTimeout(() => setMention(null), 150)}
/>
{suggestions.length > 0 && (
<ul className="mention-menu" role="listbox" aria-label={`${suggestions.length} agent(s)`}>
{suggestions.map((agent, i) => (
<li
key={agent.name}
role="option"
aria-selected={i === active}
className={i === active ? 'active' : ''}
ref={i === active ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
onMouseDown={(e) => {
e.preventDefault();
pick(agent);
}}
onMouseEnter={() => setActive(i)}
>
<strong>@{mentionName(agent.name)}</strong>
{agent.description && <span className="muted"> — {agent.description}</span>}
</li>
))}
</ul>
)}
{chain.agents.length > 0 && (
<p className="chain small">
Chaîne : {chain.agents.map((a) => `@${mentionName(a)}`).join(' → ')}
<span className="muted"> — seuls ces agents seront utilisés, dans cet ordre</span>
</p>
)}
{chain.unknown.length > 0 && agents.length > 0 && (
<p className="muted small">Agent(s) inconnu(s) dans ce dossier, ignoré(s) : {chain.unknown.map((a) => `@${a}`).join(', ')}</p>
)}
</div>
);
}
84 changes: 84 additions & 0 deletions apps/maria/src/components/AgentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

import type { AgentsSummary, AgentStatus } from '@/lib/agents';
import type { RufloAgent } from '@/lib/types';

const STATUS: Record<AgentStatus, { label: string; className: string }> = {
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 (
<div className="agents card">
<h2>
Agents
{running > 0 && <span className="muted small"> · {running} en cours</span>}
</h2>
<ul className="agent-list">
<li className="agent-item">
<div className="agent-head">
<span className="agent">MarIA</span>
<span className="muted small">agent principal · {mainActions} action(s)</span>
</div>
</li>
{subAgents.map((a) => (
<li key={a.id} className={`agent-item ${a.parentId ? 'nested' : ''}`}>
<div className="agent-head">
<span className="agent">{a.type}</span>
<span className={`badge ${STATUS[a.status].className}`}>{STATUS[a.status].label}</span>
</div>
{a.description && <div className="small">{a.description}</div>}
<div className="muted small agent-last">
{a.actions} action(s){a.lastAction ? ` · ${a.lastAction}` : ''}
</div>
</li>
))}
</ul>
{subAgents.length === 0 && (
<p className="muted small">{finished ? 'Aucun sous-agent lancé.' : 'Pas encore de sous-agent.'}</p>
)}

{(rufloEntries.length > 0 || rufloAgents) && (
<div className="ruflo">
<h3 className="small">Ruflo</h3>
{rufloEntries.length > 0 && (
<p className="muted small">
Appels : {rufloEntries.map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(', ')}
</p>
)}
{rufloAgents === null && rufloEntries.length > 0 && (
<p className="muted small">{finished ? 'Lecture du registre…' : 'Registre lu en fin de mission.'}</p>
)}
{rufloAgents && rufloAgents.length === 0 && <p className="muted small">Registre vide.</p>}
{rufloAgents && rufloAgents.length > 0 && (
<ul className="agent-list">
{rufloAgents.map((a) => (
<li key={a.agentId} className="agent-item">
<div className="agent-head">
<span className="agent">{a.agentType}</span>
<span className="muted small">{a.status}</span>
</div>
<div className="muted small">{a.agentId}</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
59 changes: 59 additions & 0 deletions apps/maria/src/components/BranchPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<div className="branch card">
<h2>Branche</h2>
<div className="agent-head">
<code>{mission.branch}</code>
<span className={`badge ${state.className}`}>{state.label}</span>
</div>
{mission.worktree_path && mission.worktree_state === 'active' && (
<p className="muted small">Worktree : {mission.worktree_path}</p>
)}
{!finished && mission.worktree_state === 'active' && (
<p className="muted small">Les modifications seront commitées sur la branche à la fin de la mission.</p>
)}
{pending && <p className="muted small">{pending === 'merge' ? 'Fusion en cours…' : 'Suppression en cours…'}</p>}
{mission.worktree_error && <p className="error small">{mission.worktree_error}</p>}
{error && <p className="error small">{error}</p>}
{canAct && (
<div className="row">
<button onClick={() => request('merge')}>Fusionner</button>
<button className="danger" onClick={() => request('discard')}>
Abandonner
</button>
</div>
)}
</div>
);
}
Loading