chore(wip): checkpoint P8/C avant chantier Codex inter-agents

Sauvegarde de l'arbre de travail en cours (persistance P8, conversations
C-series, write-portal frontend, médiation d'entrée) avant d'attaquer le
support de la délégation inter-agents pour les profils Codex.

Le round-trip inter-agent question/réponse est couvert sans tokens par
les tests loopback existants (state::mcp_e2e_loopback_tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:42:53 +02:00
parent 4509f0db9d
commit fdcf16c387
76 changed files with 3783 additions and 1404 deletions

View File

@ -19,7 +19,8 @@
use domain::ids::ProfileId;
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport, StructuredAdapter,
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
/// A fixed UUID namespace used to derive stable ids for reference profiles.
@ -161,7 +162,9 @@ mod mcp_tests {
let mcp = profile(slug).mcp.expect("mcp present");
assert_eq!(
mcp.config,
McpConfigStrategy::ConfigFile { target: ".mcp.json".to_owned() },
McpConfigStrategy::ConfigFile {
target: ".mcp.json".to_owned()
},
"profile `{slug}` should declare `.mcp.json`"
);
assert_eq!(mcp.transport, McpTransport::Stdio);

View File

@ -461,8 +461,8 @@ impl ChangeAgentProfile {
mutated_agent = Some(agent);
}
}
let agent = mutated_agent
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let agent =
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let manifest = AgentManifest::new(manifest.version, entries)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts
@ -524,8 +524,10 @@ impl ChangeAgentProfile {
// Capture the preserved pair id (stable across all leaves of this
// agent) — used to relaunch with handoff re-injection (P7).
if pair_id.is_none() {
if let Some(cid) =
named.tree.leaf(leaf_id).and_then(|l| l.conversation_id.clone())
if let Some(cid) = named
.tree
.leaf(leaf_id)
.and_then(|l| l.conversation_id.clone())
{
pair_id = Some(cid);
}
@ -1300,14 +1302,13 @@ impl LaunchAgent {
// pure partagée avec `resolve_conversation` (aucun couplage à
// l'orchestrateur). C'est cet id — et **non** l'id de session moteur —
// qui retrouve log + handoff au (re)lancement (P7) et survit au swap.
let pair_conversation_id =
input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair(
ConversationParty::User,
ConversationParty::agent(agent.id),
)
.to_string()
});
let pair_conversation_id = input.conversation_id.clone().unwrap_or_else(|| {
ConversationId::for_pair(
ConversationParty::User,
ConversationParty::agent(agent.id),
)
.to_string()
});
return self
.launch_structured(
factory.as_ref(),
@ -1679,10 +1680,18 @@ impl LaunchAgent {
///
/// Dispatch by [`McpConfigStrategy`]:
/// - `ConfigFile { target }` → write `<run_dir>/<target>` (e.g. `.mcp.json`) with
/// the IdeA MCP server declaration. **Non-clobbering and best-effort**, exactly
/// like [`Self::seed_cli_permissions`]: an existing file (possibly user-edited)
/// is left untouched, and any write/exists failure is swallowed so it **never**
/// fails the launch.
/// the IdeA MCP server declaration. **Best-effort** (any write/exists failure is
/// swallowed so it **never** fails the launch), with two clobber regimes:
/// * with an injected [`McpRuntime`] (real app-tauri launch) the file is
/// **regenerated and clobbered on every (re)launch**, exactly like the
/// convention file. The declaration's `command` carries the **stable** IdeA
/// exe path (`$APPIMAGE`) and the project's live endpoint — both drift between
/// runs (the AppImage mount path changes at each remount/reboot), so a stale
/// `.mcp.json` would point the bridge at a dead binary/socket. `.mcp.json` is
/// IdeA-managed (not user-edited), so clobbering is safe;
/// * without a runtime (orchestrator / hot-swap / tests) only a degraded minimal
/// declaration is available, so the write stays **non-clobbering** (mirrors
/// [`Self::seed_cli_permissions`]) — never overwriting a real declaration.
/// - `Flag { flag }` → append the flag + run-dir config path to [`SpawnSpec::args`].
/// - `Env { var }` → append the variable (run-dir config path) to [`SpawnSpec::env`].
///
@ -1708,17 +1717,33 @@ impl LaunchAgent {
};
match &mcp.config {
domain::profile::McpConfigStrategy::ConfigFile { target } => {
// Non-clobbering, best-effort — mirrors `seed_cli_permissions`.
let path = RemotePath::new(join(run_dir, target));
match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let declaration = mcp_server_declaration(mcp.transport, runtime);
// Best-effort: a write failure must not fail the launch.
let declaration = mcp_server_declaration(mcp.transport, runtime);
match runtime {
// Real launch (app-tauri injected the runtime): the declaration
// carries the **stable** IdeA executable path (`$APPIMAGE`, resolved
// by `idea_exe_path`) and the project's live loopback endpoint. Both
// can drift between runs — the AppImage internal mount path changes
// at every remount/reboot — so the file MUST be regenerated and
// **clobbered** on every (re)launch, exactly like the convention file
// (`apply_injection`). `.mcp.json` is IdeA-managed, not user-edited,
// so there is nothing to preserve. Best-effort: a write failure must
// never fail the launch.
Some(_) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
// An exists() probe failure is swallowed too (best-effort).
Err(_) => {}
// Internal launch (orchestrator / hot-swap / tests): we only have a
// degraded **minimal** declaration (no endpoint/project/requester).
// Stay non-clobbering — mirrors `seed_cli_permissions` — so we never
// overwrite a real declaration previously written by an app-tauri
// launch with the minimal one.
None => match self.fs.exists(&path).await {
Ok(true) => {}
Ok(false) => {
let _ = self.fs.write(&path, declaration.as_bytes()).await;
}
Err(_) => {}
},
}
}
domain::profile::McpConfigStrategy::Flag { flag } => {
@ -1976,6 +2001,7 @@ fn claude_settings_seed(project_root: &str) -> String {
]
}},
"skipDangerousModePermissionPrompt": true,
"enabledMcpjsonServers": ["idea"],
"sandbox": {{
"enabled": false
}}
@ -2347,11 +2373,7 @@ mod tests {
// Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
assert!(
doc.contains(
"- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"
)
);
assert!(doc.contains("- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"));
// Deterministic order: first entry precedes the second.
let alpha_at = doc.find("[Alpha]").unwrap();
@ -2533,15 +2555,7 @@ mod tests {
MemoryType::Project,
)];
let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[],
&memory,
Some(&h),
false,
);
let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false);
assert!(
doc.contains("# Reprise de la conversation"),
@ -2636,6 +2650,8 @@ mod tests {
// Valid JSON.
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
// IdeA MCP server pre-approved so idea_* tools load without a prompt.
assert_eq!(parsed["enabledMcpjsonServers"][0], "idea");
assert_eq!(
parsed["permissions"]["additionalDirectories"][0],
"/home/me/proj"

View File

@ -20,19 +20,17 @@ pub use structured::send_blocking;
pub use catalogue::{reference_profile_id, reference_profiles, selectable_reference_profiles};
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
};
pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
ProviderSessionProvider,
LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput, McpRuntime,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
UpdateAgentContext,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, McpRuntime, ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, StructuredSessionDescriptor, UpdateAgentContext,
UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,
};
pub use usecases::{
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
DeleteProfileInput, DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState,

View File

@ -35,8 +35,8 @@ pub use reconcile::{ReconcileLayouts, ReconcileLayoutsInput, ReconcileLayoutsOut
pub use snapshot::{
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
};
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub(crate) use store::{persist_doc, resolve_doc};
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
pub use usecases::{
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
MutateLayoutInput, MutateLayoutOutput,

View File

@ -109,12 +109,7 @@ impl LayoutOperation {
direction,
new_leaf,
container,
} => tree.split(
*target,
*direction,
LeafCell::new(*new_leaf),
*container,
),
} => tree.split(*target, *direction, LeafCell::new(*new_leaf), *container),
Self::Merge {
container,
keep_index,

View File

@ -28,19 +28,19 @@ pub mod terminal;
pub mod window;
pub use agent::{
reference_profile_id, reference_profiles, selectable_reference_profiles, ChangeAgentProfile,
ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles, ConfigureProfilesInput,
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
HandoffProvider, ProviderSessionProvider,
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput, McpRuntime,
ListResumableAgentsOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile,
SaveProfileInput, SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext,
UpdateAgentContextInput, send_blocking, AGENT_MEMORY_RECALL_BUDGET,
reference_profile_id, reference_profiles, selectable_reference_profiles, send_blocking,
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, ConfigureProfiles,
ConfigureProfilesInput, ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput,
CreateAgentOutput, DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput,
DetectProfiles, DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput,
HandoffProvider, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, McpRuntime, ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET,
};
pub use conversation::RecordTurn;
pub use embedder::{

View File

@ -29,9 +29,7 @@ use domain::conversation::ConversationParty;
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{
AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath,
};
use domain::ports::{AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath};
use domain::{AgentId, Project};
use crate::error::AppError;
@ -90,7 +88,11 @@ impl ReadContext {
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
) -> Self {
Self { guard, contexts, fs }
Self {
guard,
contexts,
fs,
}
}
/// Reads the requested context, returning its Markdown body.
@ -98,7 +100,11 @@ impl ReadContext {
/// # Errors
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
let ReadContextInput { project, target, requester } = input;
let ReadContextInput {
project,
target,
requester,
} = input;
match target {
None => {
// Global project context: shared read-lease, then read the root file.
@ -109,8 +115,8 @@ impl ReadContext {
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let bytes = self.fs.read(&path).await?;
let text = String::from_utf8(bytes)
.map_err(|e| AppError::Invalid(e.to_string()))?;
let text =
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(MarkdownDoc::new(text))
}
Some(name) => {
@ -173,7 +179,12 @@ impl ProposeContext {
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
) -> Self {
Self { guard, contexts, fs, clock }
Self {
guard,
contexts,
fs,
clock,
}
}
/// Applies the proposal: direct write under a write-lease, or a materialised
@ -182,7 +193,12 @@ impl ProposeContext {
/// # Errors
/// [`AppError`] when the agent does not exist or the store/fs fails.
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
let ProposeContextInput { project, target, content, requester } = input;
let ProposeContextInput {
project,
target,
content,
requester,
} = input;
match target {
Some(name) => {
// Per-agent context: direct write under an exclusive write-lease.
@ -273,7 +289,11 @@ impl ReadMemory {
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
/// is [`AppError::Invalid`].
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
let ReadMemoryInput { project, slug, requester } = input;
let ReadMemoryInput {
project,
slug,
requester,
} = input;
match slug {
Some(raw) => {
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
@ -329,7 +349,12 @@ impl WriteMemory {
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
/// failure.
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
let WriteMemoryInput { project, slug, content, requester } = input;
let WriteMemoryInput {
project,
slug,
content,
requester,
} = input;
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
let _lease = self
.guard
@ -527,11 +552,7 @@ mod tests {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(Vec::new())
}
async fn get(
&self,
_root: &ProjectPath,
slug: &MemorySlug,
) -> Result<Memory, MemoryError> {
async fn get(&self, _root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
let body = self
.notes
.lock()
@ -556,11 +577,7 @@ mod tests {
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
Ok(())
}
async fn delete(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<(), MemoryError> {
async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(
@ -805,7 +822,10 @@ mod tests {
guard.acquire_write(agent_party(2), slug.clone()),
)
.await;
assert!(blocked.is_err(), "a second writer must block while w1 holds");
assert!(
blocked.is_err(),
"a second writer must block while w1 holds"
);
drop(w1);
let w2 = tokio::time::timeout(
Duration::from_millis(200),

View File

@ -19,15 +19,15 @@ use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use domain::conversation::{
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
};
use domain::conversation::{ConversationParty, ConversationRegistry, SessionRef, WaitForGraph};
use domain::conversation_log::{ConversationTurn, TurnId, TurnRole};
use domain::input::{InputMediator, InputSource};
use domain::input::{InputMediator, InputSource, SubmitConfig};
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{Clock, EventBus, ProfileStore, PtyHandle};
use domain::project::ProjectPath;
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use domain::{
AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project,
};
use crate::conversation::RecordTurn;
@ -416,7 +416,10 @@ impl OrchestratorService {
target,
content,
requester,
} => self.propose_context(project, target, content, requester).await,
} => {
self.propose_context(project, target, content, requester)
.await
}
OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await
}
@ -453,10 +456,7 @@ impl OrchestratorService {
})
.await?;
Ok(OrchestratorOutcome {
detail: format!(
"read {} context",
target.as_deref().unwrap_or("project")
),
detail: format!("read {} context", target.as_deref().unwrap_or("project")),
reply: Some(md.into_string()),
})
}
@ -481,15 +481,17 @@ impl OrchestratorService {
})
.await?;
let detail = match outcome {
ProposeOutcome::Written => format!(
"wrote {} context",
target.as_deref().unwrap_or("project")
),
ProposeOutcome::Written => {
format!("wrote {} context", target.as_deref().unwrap_or("project"))
}
ProposeOutcome::Proposed { path } => {
format!("filed proposal for project context at {path}")
}
};
Ok(OrchestratorOutcome { detail, reply: None })
Ok(OrchestratorOutcome {
detail,
reply: None,
})
}
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
@ -755,8 +757,8 @@ impl OrchestratorService {
.await?;
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern);
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern, submit);
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
@ -866,8 +868,8 @@ impl OrchestratorService {
let handle = self
.ensure_live_pty(project, agent_id, conversation_id, target)
.await?;
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern);
let (prompt_pattern, submit) = self.prompt_and_submit_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern, submit);
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
// forget: we drop the PendingReply (the human reads the terminal). The
@ -922,6 +924,34 @@ impl OrchestratorService {
})
}
/// **Ack de livraison** (ARCHITECTURE §20.3) — best-effort, observabilité only.
///
/// The frontend write-portal calls this (via the `delegation_delivered` Tauri
/// command) once it has **physically written** a delegation `ticket` into the agent's
/// native PTY, to distinguish "queued because the human line was busy" from "written"
/// in logs/observability. It **does not** change correlation: the requester's `ask`
/// is still woken by `idea_reply`→`resolve_ticket`, and the per-turn timeout/cycle
/// guards are untouched. It is a pure log no-op when nothing is wired, so a missing
/// mediator/registry never breaks the rendezvous.
pub fn note_delegation_delivered(
&self,
project: &Project,
agent_id: AgentId,
ticket: TicketId,
) {
// Observability beacon only: never mutates the mailbox/busy state nor resolves
// the ticket (that stays `idea_reply`-driven). Kept best-effort by construction —
// a pure, infallible hook so a missing mediator/registry never breaks the
// rendezvous. The application crate carries no logging framework (zero new dep);
// the ack is materialised as an `eprintln!` trace, the same lightweight channel
// the orchestrator already uses for best-effort diagnostics.
let _ = project;
eprintln!(
"[orchestrator] delegation delivered into agent {agent_id}'s native terminal \
(front ack, ticket {ticket})"
);
}
/// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
/// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
/// stable per-agent id derived from the target (legacy routing — never panics).
@ -944,7 +974,6 @@ impl OrchestratorService {
}
}
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
/// it is currently processing (Option 1, lot B-4).
///
@ -1039,19 +1068,18 @@ impl OrchestratorService {
})
.await?;
let session_id = self
.sessions
.session_for_agent(&agent_id)
.ok_or_else(|| {
AppError::Process(format!(
"agent {target} n'a pas de session terminal vivante après lancement"
))
})?;
let session_id = self.sessions.session_for_agent(&agent_id).ok_or_else(|| {
AppError::Process(format!(
"agent {target} n'a pas de session terminal vivante après lancement"
))
})?;
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
// conversations) ⇒ un prochain ask sur le même fil la réutilise.
self.bind_conversation_session(conversation_id, session_id);
self.sessions.handle(&session_id).ok_or_else(|| {
AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement"))
AppError::Process(format!(
"handle PTY de l'agent {target} introuvable après lancement"
))
})
}
@ -1318,30 +1346,41 @@ impl OrchestratorService {
)))
}
/// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's
/// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle`
/// time. Returns `None` when the agent, its profile, or the pattern is absent —
/// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout.
async fn prompt_pattern_for_agent(
/// Resolves the target agent profile's **prompt-ready pattern** (§6, lot C5) **and**
/// its **submit config** (`submit_sequence`/`submit_delay_ms`, ARCHITECTURE §20.3) in
/// a single profile lookup. Both are carried into `bind_handle_with_prompt`: the
/// pattern arms prompt detection, the submit config is echoed on the next
/// `DelegationReady` so the frontend write-portal knows how to submit. Returns
/// `(None, default)` when the agent, its profile, or the field is absent — the safe
/// fallback (no pattern ⇒ Idle only via explicit signal/timeout; no submit ⇒ the
/// front applies its own `"\r"`/~60 ms default).
async fn prompt_and_submit_for_agent(
&self,
project: &Project,
agent_id: AgentId,
) -> Option<String> {
let agent = self
) -> (Option<String>, SubmitConfig) {
let Some(agent) = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await
.ok()?
.agents
.into_iter()
.find(|a| a.id == agent_id)?;
let profiles = self.profiles.list().await.ok()?;
profiles
.into_iter()
.find(|p| p.id == agent.profile_id)
.and_then(|p| p.prompt_ready_pattern)
.ok()
.and_then(|out| out.agents.into_iter().find(|a| a.id == agent_id))
else {
return (None, SubmitConfig::default());
};
let Some(profile) = self
.profiles
.list()
.await
.ok()
.and_then(|ps| ps.into_iter().find(|p| p.id == agent.profile_id))
else {
return (None, SubmitConfig::default());
};
let submit = SubmitConfig::new(profile.submit_sequence, profile.submit_delay_ms);
(profile.prompt_ready_pattern, submit)
}
/// Resolves a human-friendly profile reference (slug like `claude-code`,

View File

@ -90,7 +90,12 @@ impl TerminalSessions {
/// per-agent lookup for the orchestrator's ask path.
#[must_use]
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
let sid = self.conversations.lock().ok()?.get(&conversation).copied()?;
let sid = self
.conversations
.lock()
.ok()?
.get(&conversation)
.copied()?;
// Only return it if the session is still live in `entries`.
self.entries
.lock()