feat(terminals): reprise de conversation par cellule + fix ordre d'écriture
Permet de recharger la conversation CLI précédente de chaque cellule à la
réouverture du projet, de façon universelle (indépendant du modèle/CLI).
- profil AgentRuntime: bloc déclaratif optionnel `session { assignFlag, resumeFlag }`
- LeafCell: `conversationId` (persistant, distinct du SessionId PTY) + `agentWasRunning`
- runtime: SessionPlan (None/Assign/Resume) + composition pure des args
- LaunchAgent: décide Assign vs Resume, génère l'UUID, remonte l'id assigné
(persistance par l'appelant via setCellConversation — découplage SRP)
- close: SnapshotRunningAgents fige `agentWasRunning` avant le kill-all
(statut clot/en cours universel, sans parsing CLI)
- SessionInspector: port optionnel best-effort + adapter ClaudeTranscriptInspector
- popup de reprise par cellule (statut + sujet/tokens si dispo), intercalée
avant le Resume auto, jamais sur le chemin reattach
fix(terminals): sérialise les écritures PTY (file FIFO par handle) — corrige
les caractères mélangés/accents dus au réordonnancement des invoke Tauri concurrents
fix(layout): l'opération `move` préservait mal les champs du leaf (perdait `agent`)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,12 +14,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
|
||||
ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError,
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
|
||||
PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan, SkillStore, SpawnSpec,
|
||||
StoreError,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
|
||||
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
||||
ManifestEntry, MarkdownDoc, NodeId, Project, ProfileId, ProjectPath, PtySize, SessionKind,
|
||||
SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
@ -327,6 +329,12 @@ pub struct LaunchAgentInput {
|
||||
pub cols: u16,
|
||||
/// The layout leaf hosting the session (a fresh node when `None`).
|
||||
pub node_id: Option<NodeId>,
|
||||
/// The persistent CLI conversation id currently recorded on the hosting cell,
|
||||
/// if any. `Some` means a previous conversation exists and the launch should
|
||||
/// **resume** it; `None` means a fresh cell (the launch may *assign* a new id
|
||||
/// when the profile supports it). The caller (which owns the layout) reads this
|
||||
/// from the leaf's [`domain::layout::LeafCell::conversation_id`].
|
||||
pub conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Output of [`LaunchAgent::execute`].
|
||||
@ -334,6 +342,13 @@ pub struct LaunchAgentInput {
|
||||
pub struct LaunchAgentOutput {
|
||||
/// The created agent terminal session.
|
||||
pub session: TerminalSession,
|
||||
/// The conversation id **assigned** by this launch, when the profile supports
|
||||
/// session assignment and the cell had none yet. The caller persists it on the
|
||||
/// hosting leaf (via the layout flow, e.g. `set_cell_conversation`) so the next
|
||||
/// open resumes instead of re-assigning. `None` when nothing new was assigned
|
||||
/// (resume of an existing id, degraded mode, or a profile without a session
|
||||
/// block) — the caller has nothing to persist.
|
||||
pub assigned_conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Launches an agent: resolve profile + context, prepare the invocation, apply
|
||||
@ -353,6 +368,7 @@ pub struct LaunchAgent {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
@ -368,6 +384,7 @@ impl LaunchAgent {
|
||||
skills: Arc<dyn SkillStore>,
|
||||
sessions: Arc<TerminalSessions>,
|
||||
events: Arc<dyn EventBus>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
@ -378,6 +395,7 @@ impl LaunchAgent {
|
||||
skills,
|
||||
sessions,
|
||||
events,
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
@ -459,6 +477,16 @@ impl LaunchAgent {
|
||||
.create_dir_all(&RemotePath::new(run_dir.as_str().to_owned()))
|
||||
.await?;
|
||||
|
||||
// 3b. Seed the CLI's permission config in the run dir so the agent runs
|
||||
// with the project's full autonomy and never blocks on per-command
|
||||
// permission prompts. The agent's cwd is the run dir, so the CLI
|
||||
// writes/reads its permission file there; without a seed, the CLI
|
||||
// accumulates narrow per-command approvals and keeps prompting.
|
||||
// Pragmatic per-CLI seed pending the universal `.ideai/permissions.json`
|
||||
// + OS-sandbox model. Non-clobbering and best-effort.
|
||||
self.seed_cli_permissions(&profile, &run_dir, &input.project.root)
|
||||
.await?;
|
||||
|
||||
// 4. Prepare the invocation (pure): command + args + injection plan + cwd.
|
||||
// The run dir is passed as the cwd base; the profile's `{agentRunDir}`
|
||||
// placeholder resolves against it.
|
||||
@ -466,9 +494,15 @@ impl LaunchAgent {
|
||||
content: content.clone(),
|
||||
relative_path: agent.context_path.clone(),
|
||||
};
|
||||
let mut spec = self
|
||||
.runtime
|
||||
.prepare_invocation(&profile, &prepared, &run_dir)?;
|
||||
// 4a. Resolve the session intention (T4). The conversation id is a property
|
||||
// of the *cell*, not the PTY: the caller (which owns the layout) passes
|
||||
// the cell's current `conversation_id`. Any id this launch *assigns* is
|
||||
// returned in the output so the caller persists it on the leaf.
|
||||
let (session_plan, assigned_conversation_id) =
|
||||
self.resolve_session_plan(&profile, input.conversation_id.clone());
|
||||
let mut spec =
|
||||
self.runtime
|
||||
.prepare_invocation(&profile, &prepared, &run_dir, &session_plan)?;
|
||||
|
||||
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
||||
// the injection plan side effects *before* spawning.
|
||||
@ -503,7 +537,102 @@ impl LaunchAgent {
|
||||
session_id,
|
||||
});
|
||||
|
||||
Ok(LaunchAgentOutput { session })
|
||||
Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the [`SessionPlan`] for a launch from the profile's session
|
||||
/// strategy and the cell's current `conversation_id` (T4).
|
||||
///
|
||||
/// Returns the plan *and* — when this launch mints a fresh id — that id, so the
|
||||
/// caller can persist it on the hosting leaf. The id is only generated for an
|
||||
/// `Assign` (profile has a `session` block with an `assign_flag`, and the cell
|
||||
/// had no id yet); every other branch returns `None` (nothing to persist).
|
||||
///
|
||||
/// Branches:
|
||||
/// - cell already has an id ⇒ [`SessionPlan::Resume`] (reopen) — no new id;
|
||||
/// - no id, profile has `session.assign_flag` ⇒ mint a UUID, [`SessionPlan::Assign`];
|
||||
/// - no id, profile has `session` but no `assign_flag` (degraded) ⇒
|
||||
/// [`SessionPlan::None`] (nothing to resume on a first launch; the adapter
|
||||
/// uses the bare resume flag only on later reopens);
|
||||
/// - profile without a `session` block ⇒ [`SessionPlan::None`] (legacy).
|
||||
fn resolve_session_plan(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
cell_conversation_id: Option<String>,
|
||||
) -> (SessionPlan, Option<String>) {
|
||||
// No session strategy at all: behave exactly as before.
|
||||
let Some(session) = &profile.session else {
|
||||
return (SessionPlan::None, None);
|
||||
};
|
||||
|
||||
// The cell already carries a conversation: resume it (no new id minted).
|
||||
if let Some(conversation_id) = cell_conversation_id {
|
||||
return (SessionPlan::Resume { conversation_id }, None);
|
||||
}
|
||||
|
||||
// Fresh cell. Only mint+assign an id when the profile can assign one;
|
||||
// otherwise (degraded mode) the first launch has nothing to resume.
|
||||
if session.assign_flag.is_some() {
|
||||
let conversation_id = self.ids.new_uuid().to_string();
|
||||
(
|
||||
SessionPlan::Assign {
|
||||
conversation_id: conversation_id.clone(),
|
||||
},
|
||||
Some(conversation_id),
|
||||
)
|
||||
} else {
|
||||
(SessionPlan::None, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Seeds the agent's run dir with the CLI permission config matching its
|
||||
/// context-injection convention, so the agent inherits the project's autonomy
|
||||
/// instead of prompting per command.
|
||||
///
|
||||
/// Conditioned on the CLI convention (only Claude Code — convention file
|
||||
/// `CLAUDE.md` — has a known seed today); a no-op for any other CLI.
|
||||
/// Best-effort and **non-clobbering**: an existing file (possibly user-edited)
|
||||
/// is left untouched.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::FileSystem`] if the directory/file cannot be written.
|
||||
async fn seed_cli_permissions(
|
||||
&self,
|
||||
profile: &AgentProfile,
|
||||
run_dir: &ProjectPath,
|
||||
project_root: &ProjectPath,
|
||||
) -> Result<(), AppError> {
|
||||
let is_claude = matches!(
|
||||
&profile.context_injection,
|
||||
ContextInjection::ConventionFile { target }
|
||||
if target
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(target)
|
||||
.eq_ignore_ascii_case("CLAUDE.md")
|
||||
);
|
||||
if !is_claude {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let settings_path =
|
||||
RemotePath::new(format!("{}/.claude/settings.local.json", run_dir.as_str()));
|
||||
if self.fs.exists(&settings_path).await? {
|
||||
return Ok(());
|
||||
}
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(format!("{}/.claude", run_dir.as_str())))
|
||||
.await?;
|
||||
self.fs
|
||||
.write(
|
||||
&settings_path,
|
||||
claude_settings_seed(project_root.as_str()).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies the context-injection plan that must happen *before* spawn:
|
||||
@ -561,10 +690,76 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
/// # Errors
|
||||
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
|
||||
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
|
||||
fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
|
||||
pub(crate) fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
|
||||
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
|
||||
}
|
||||
|
||||
/// Builds the Claude Code permission seed (`.claude/settings.local.json`) written
|
||||
/// into an agent's run dir: full project autonomy (`bypassPermissions` + broad
|
||||
/// Read/Edit/Write/Bash) with the project root granted as an additional working
|
||||
/// directory (the cwd is the run dir, the agent works on the root above it), while
|
||||
/// keeping destructive/out-of-project commands denied. `project_root` is embedded
|
||||
/// verbatim; it is JSON-escaped to stay valid for unusual paths.
|
||||
///
|
||||
/// Pure (no I/O), so it is unit-testable in isolation.
|
||||
#[must_use]
|
||||
fn claude_settings_seed(project_root: &str) -> String {
|
||||
let root = json_escape(project_root);
|
||||
format!(
|
||||
r#"{{
|
||||
"permissions": {{
|
||||
"defaultMode": "bypassPermissions",
|
||||
"additionalDirectories": [
|
||||
"{root}"
|
||||
],
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(sudo *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(rm -rf $HOME*)",
|
||||
"Bash(mkfs*)",
|
||||
"Bash(dd if=*)",
|
||||
"Bash(shutdown*)",
|
||||
"Bash(reboot*)"
|
||||
]
|
||||
}},
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"sandbox": {{
|
||||
"enabled": false
|
||||
}}
|
||||
}}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimal JSON string escaper for embedding a filesystem path in the settings
|
||||
/// seed (handles the characters that actually occur in paths: backslash, quote,
|
||||
/// and control chars).
|
||||
fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||
/// so it must be told where to work), the agent's persona `.md`, then the bodies
|
||||
@ -700,4 +895,38 @@ mod tests {
|
||||
assert!(doc.contains("## refactor"));
|
||||
assert!(doc.contains("## review"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
|
||||
let json = claude_settings_seed("/home/me/proj");
|
||||
|
||||
// Full autonomy.
|
||||
assert!(json.contains("\"defaultMode\": \"bypassPermissions\""));
|
||||
assert!(json.contains("\"Bash\""));
|
||||
// Project root granted as an additional working directory.
|
||||
assert!(json.contains("\"/home/me/proj\""));
|
||||
// Destructive guardrails preserved.
|
||||
assert!(json.contains("Bash(sudo *)"));
|
||||
assert!(json.contains("Bash(rm -rf /)"));
|
||||
assert!(json.contains("Bash(mkfs*)"));
|
||||
// Valid JSON.
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).expect("seed is valid JSON");
|
||||
assert_eq!(parsed["permissions"]["defaultMode"], "bypassPermissions");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
"/home/me/proj"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_settings_seed_escapes_paths_for_valid_json() {
|
||||
// A path with a backslash and a quote must not break the JSON.
|
||||
let json = claude_settings_seed(r#"/weird\path"x"#);
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&json).expect("seed with odd path is valid JSON");
|
||||
assert_eq!(
|
||||
parsed["permissions"]["additionalDirectories"][0],
|
||||
r#"/weird\path"x"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user