Skip to main content

cli/cli/commands/
thread.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Thread commands.
3
4use std::{
5    collections::{BTreeSet, HashMap},
6    path::PathBuf,
7    process,
8};
9
10use anyhow::{Result, anyhow};
11use chrono::Utc;
12use objects::{
13    object::{ChangeId, State},
14    store::{AgentEntry, AgentRegistry, AgentStatus, current_boot_id},
15};
16use refs::{Head, RefExpectation, RefUpdate};
17use repo::{
18    AgentUsageSummary, GitOverlayBranchTip, GitRemoteTrackingStatus, Repository,
19    RepositoryOperationStatus, Thread, ThreadConfidenceSummary, ThreadFreshness,
20    ThreadImpactCategory, ThreadIntegrationPolicy, ThreadManager, ThreadMode, ThreadRuntimeOverlay,
21    ThreadState, ThreadVerificationSummary, ThreadView, describe_thread_advice,
22};
23use serde::Serialize;
24
25use super::{
26    mount_lifecycle,
27    operator_loop::primary_next_action,
28    snapshot::{ensure_current_state, summarize_confidence, summarize_verification},
29    thread_cmd::refresh_thread_freshness,
30    worktree_cmd::{
31        helpers::{prepare_worktree_target, write_isolated_checkout},
32        shared_target,
33    },
34};
35use crate::{
36    cli::{Cli, ThreadListArgs, ThreadStartArgs, WorkspaceModeArg, should_output_json, style},
37    config::{UserConfig, UserThreadWorkspaceMode},
38};
39
40#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
41#[serde(rename_all = "kebab-case")]
42pub enum CoordinationStatus {
43    Clean,
44    Ahead,
45    Diverged,
46    Blocked,
47    MergeReady,
48}
49
50impl std::fmt::Display for CoordinationStatus {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::Clean => write!(f, "clean"),
54            Self::Ahead => write!(f, "ahead"),
55            Self::Diverged => write!(f, "diverged"),
56            Self::Blocked => write!(f, "blocked"),
57            Self::MergeReady => write!(f, "merge-ready"),
58        }
59    }
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct ThreadSummary {
64    pub name: String,
65    pub operation: Option<RepositoryOperationStatus>,
66    pub remote_tracking: Option<GitRemoteTrackingStatus>,
67    pub base_state: Option<String>,
68    pub base_root: Option<String>,
69    pub current_state: Option<String>,
70    pub path: Option<String>,
71    pub execution_path: Option<String>,
72    pub session_id: Option<String>,
73    pub heddle_session_id: Option<String>,
74    pub actor: Option<ThreadActorInfo>,
75    pub harness: Option<String>,
76    pub thinking_level: Option<String>,
77    pub native_actor_key: Option<String>,
78    pub native_parent_actor_key: Option<String>,
79    pub probe_source: Option<String>,
80    pub probe_confidence: Option<f32>,
81    pub usage_summary: Option<AgentUsageSummary>,
82    pub last_progress_at: Option<String>,
83    pub last_activity_at: Option<String>,
84    pub report_flush_state: Option<String>,
85    pub attach_reason: Option<String>,
86    pub thread_mode: Option<ThreadMode>,
87    pub thread_state: Option<ThreadState>,
88    pub freshness: Option<ThreadFreshness>,
89    pub visibility: String,
90    pub target_thread: Option<String>,
91    pub parent_thread: Option<String>,
92    pub child_threads: Vec<String>,
93    pub sibling_threads: Vec<String>,
94    pub stack_depth: usize,
95    pub stale_from_parent: bool,
96    pub task: Option<String>,
97    pub changed_paths: Vec<String>,
98    pub promotion_suggested: bool,
99    pub impact_categories: Vec<ThreadImpactCategory>,
100    pub heavy_impact_paths: Vec<String>,
101    pub verification_summary: ThreadVerificationSummary,
102    pub confidence_summary: ThreadConfidenceSummary,
103    pub integration_policy_result: ThreadIntegrationPolicy,
104    pub coordination_status: CoordinationStatus,
105    pub is_current: bool,
106    pub is_isolated: bool,
107    pub thread_health: String,
108    pub blockers: Vec<String>,
109    pub recommended_action: String,
110    pub git_branch_tip: Option<String>,
111    pub history_imported: bool,
112    /// Mirror of [`repo::ThreadRecord::auto`]. `true` when the thread
113    /// was created by a harness integration rather than an explicit
114    /// user verb. Used by `heddle thread list` (default-hides) and
115    /// `heddle thread cleanup --auto`.
116    pub auto: bool,
117    /// Mirror of [`repo::ThreadRecord::shared_target_dir`]. When
118    /// present, the thread's checkout has its cargo `target/`
119    /// redirected to this absolute path via a `.cargo/config.toml`
120    /// committed inside the checkout. `None` for threads using
121    /// cargo's default per-checkout `target/`.
122    pub shared_target_dir: Option<String>,
123}
124
125#[derive(Debug, Clone, Serialize)]
126pub struct ThreadActorInfo {
127    pub provider: Option<String>,
128    pub model: Option<String>,
129}
130
131impl ThreadSummary {
132    fn from_view(view: ThreadView, coordination_status: CoordinationStatus) -> Self {
133        let mode = view.record.mode.clone();
134        ThreadSummary {
135            name: view.record.thread,
136            operation: None,
137            remote_tracking: None,
138            base_state: Some(view.record.base_state),
139            base_root: Some(view.record.base_root),
140            current_state: view.record.current_state,
141            path: view
142                .runtime
143                .materialized_path
144                .as_ref()
145                .or(view.runtime.path.as_ref())
146                .map(|path| path.display().to_string()),
147            execution_path: view
148                .runtime
149                .execution_path
150                .as_ref()
151                .map(|path| path.display().to_string()),
152            session_id: view.runtime.session_id,
153            heddle_session_id: view.runtime.heddle_session_id,
154            actor: match (view.runtime.provider, view.runtime.model) {
155                (None, None) => None,
156                (provider, model) => Some(ThreadActorInfo { provider, model }),
157            },
158            harness: view.runtime.harness,
159            thinking_level: view.runtime.thinking_level,
160            native_actor_key: view.runtime.native_actor_key,
161            native_parent_actor_key: view.runtime.native_parent_actor_key,
162            probe_source: view.runtime.probe_source,
163            probe_confidence: view.runtime.probe_confidence,
164            usage_summary: view.runtime.usage_summary,
165            last_progress_at: view.runtime.last_progress_at.map(|ts| ts.to_rfc3339()),
166            last_activity_at: Some(view.record.updated_at.to_rfc3339()),
167            report_flush_state: view.runtime.report_flush_state,
168            attach_reason: view.runtime.attach_reason,
169            thread_mode: Some(mode.clone()),
170            thread_state: Some(view.record.state),
171            freshness: Some(view.record.freshness),
172            visibility: visibility_label(&mode).to_string(),
173            target_thread: view.record.target_thread,
174            parent_thread: view.record.parent_thread,
175            child_threads: Vec::new(),
176            sibling_threads: Vec::new(),
177            stack_depth: 0,
178            stale_from_parent: false,
179            task: view.record.task,
180            changed_paths: view.record.changed_paths,
181            promotion_suggested: view.record.promotion_suggested,
182            impact_categories: view.record.impact_categories,
183            heavy_impact_paths: view.record.heavy_impact_paths,
184            verification_summary: view.record.verification_summary,
185            confidence_summary: view.record.confidence_summary,
186            integration_policy_result: view.record.integration_policy_result,
187            coordination_status,
188            is_current: view.is_current,
189            is_isolated: view.is_isolated,
190            thread_health: "clean".to_string(),
191            blockers: Vec::new(),
192            recommended_action: String::new(),
193            git_branch_tip: None,
194            history_imported: true,
195            auto: view.record.auto,
196            shared_target_dir: view
197                .record
198                .shared_target_dir
199                .as_ref()
200                .map(|p| p.display().to_string()),
201        }
202    }
203}
204
205#[derive(Serialize)]
206struct ThreadListOutput {
207    repository_capability: String,
208    storage_model: String,
209    hosted_enabled: bool,
210    threads: Vec<ThreadSummary>,
211    current: Option<String>,
212    /// Carried for the human-readable renderer only. Not part of the
213    /// JSON contract: import-hint information is exposed via
214    /// `heddle bridge git status --json` instead.
215    #[serde(skip)]
216    git_overlay_import_hint: Option<ThreadListGitOverlayImportHintOutput>,
217}
218
219#[derive(Serialize)]
220struct ThreadListGitOverlayImportHintOutput {
221    current_branch: String,
222    missing_branch_count: usize,
223    missing_branches: Vec<String>,
224    recommended_command: String,
225}
226
227#[derive(Serialize)]
228pub(crate) struct ThreadOpOutput {
229    pub name: String,
230    pub message: String,
231    pub thread: Option<ThreadSummary>,
232    pub path: Option<String>,
233    pub execution_path: Option<String>,
234}
235
236#[derive(Serialize)]
237pub(crate) struct ThreadCaptureOutput {
238    pub change_id: String,
239    pub created_at: String,
240    pub intent: Option<String>,
241    pub confidence: Option<f32>,
242    pub agent: Option<String>,
243    pub message: String,
244    /// Per-capture file count delta vs the parent state. `None` for
245    /// captures with no parent (the bootstrap snapshot of a fresh
246    /// repo) and when the diff cannot be computed (parent state
247    /// missing from the local store).
248    pub summary: Option<ThreadCaptureSummary>,
249}
250
251#[derive(Serialize)]
252pub(crate) struct ThreadCaptureSummary {
253    pub added: usize,
254    pub modified: usize,
255    pub deleted: usize,
256    pub total: usize,
257}
258
259pub fn cmd_start(cli: &Cli, args: ThreadStartArgs) -> Result<()> {
260    let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
261    let print_cd = args.print_cd_path;
262    let output = start_thread(&repo, args)?;
263    if print_cd {
264        return render_cd_path(&output);
265    }
266    render_thread_op(cli, output)
267}
268
269/// Print only the new thread's checkout path on stdout, then exit. Used by
270/// shell wrappers (`dir=$(heddle start foo --print-cd-path) && cd "$dir"`).
271/// Returns an error when the operation didn't produce a checkout path —
272/// callers that pass `--print-cd-path` and get an error should fall back to
273/// `heddle start foo` for the full report.
274fn render_cd_path(output: &ThreadOpOutput) -> Result<()> {
275    let path = output
276        .thread
277        .as_ref()
278        .and_then(|t| t.path.as_deref())
279        .ok_or_else(|| {
280            anyhow::anyhow!(
281                "this thread has no filesystem checkout path; `--print-cd-path` only works for materialized workspaces"
282            )
283        })?;
284    println!("{path}");
285    Ok(())
286}
287
288pub(crate) fn cmd_thread_captures(
289    cli: &Cli,
290    repo: &Repository,
291    thread: &str,
292    limit: usize,
293) -> Result<()> {
294    let captures = collect_thread_captures(repo, thread, limit)?;
295    if should_output_json(cli, Some(repo.config())) {
296        println!("{}", serde_json::to_string(&captures)?);
297        return Ok(());
298    }
299
300    println!("{}", style::section(&format!("Captures on {thread}")));
301    if captures.is_empty() {
302        println!(
303            "  {}",
304            style::dim("No captures recorded on this thread yet.")
305        );
306        return Ok(());
307    }
308    for capture in captures {
309        let confidence = capture
310            .confidence
311            .map(|value| format!("{value:.2}"))
312            .unwrap_or_else(|| "None".to_string());
313        println!(
314            "  {} {} {}",
315            style::accent(&capture.change_id),
316            capture.message,
317            style::dim(&format!("confidence {confidence}"))
318        );
319        println!("    {}", style::dim(&capture.created_at));
320        if let Some(agent) = capture.agent {
321            println!("    {}", style::field("Agent", &agent));
322        }
323    }
324    Ok(())
325}
326
327fn collect_thread_captures(
328    repo: &Repository,
329    thread: &str,
330    limit: usize,
331) -> Result<Vec<ThreadCaptureOutput>> {
332    let current = repo
333        .refs()
334        .get_thread(thread)?
335        .ok_or_else(|| anyhow!("Thread not found: {thread}"))?;
336    let base = ThreadManager::new(repo.heddle_dir())
337        .load(thread)?
338        .map(|thread| thread.base_state);
339    let mut out = Vec::new();
340    let mut cursor = Some(current);
341    while let Some(change_id) = cursor {
342        if base.as_deref() == Some(change_id.short().as_str())
343            || base.as_deref().and_then(|base| ChangeId::parse(base).ok()) == Some(change_id)
344        {
345            break;
346        }
347        let Some(state) = repo.store().get_state(&change_id)? else {
348            break;
349        };
350        if state
351            .intent
352            .as_deref()
353            .is_some_and(|intent| !intent.starts_with("Bootstrap "))
354        {
355            let summary = capture_diff_summary(repo, &state);
356            out.push(thread_capture_output(&state, summary));
357        }
358        if out.len() >= limit {
359            break;
360        }
361        cursor = state.parents.first().copied();
362    }
363    Ok(out)
364}
365
366/// Summarize the file-count delta between a state and its first
367/// parent. Best-effort: returns `None` when there is no parent (root
368/// capture) or when the parent state isn't materialized in the local
369/// store (e.g. shallow imports).
370fn capture_diff_summary(repo: &Repository, state: &State) -> Option<ThreadCaptureSummary> {
371    let parent_id = state.parents.first().copied()?;
372    let parent = repo.store().get_state(&parent_id).ok().flatten()?;
373    let changes = repo.diff_trees(&parent.tree, &state.tree).ok()?;
374    Some(ThreadCaptureSummary {
375        added: changes.added_count(),
376        modified: changes.modified_count(),
377        deleted: changes.deleted_count(),
378        total: changes.len(),
379    })
380}
381
382fn thread_capture_output(
383    state: &State,
384    summary: Option<ThreadCaptureSummary>,
385) -> ThreadCaptureOutput {
386    let agent = state
387        .attribution
388        .agent
389        .as_ref()
390        .map(|agent| format!("{}/{}", agent.provider, agent.model));
391    let message = state
392        .intent
393        .clone()
394        .unwrap_or_else(|| format!("Capture {}", state.change_id.short()));
395    ThreadCaptureOutput {
396        change_id: state.change_id.short(),
397        created_at: state.created_at.to_rfc3339(),
398        intent: state.intent.clone(),
399        confidence: state.confidence,
400        agent,
401        message,
402        summary,
403    }
404}
405
406pub fn collect_thread_summaries(repo: &Repository) -> Result<Vec<ThreadSummary>> {
407    let threads = repo.refs().list_threads()?;
408    let current = repo.current_lane()?;
409    let operation = repo.operation_status()?;
410    let remote_tracking = repo.git_remote_tracking_status().unwrap_or(None);
411    let import_hint = repo.git_overlay_import_hint().unwrap_or(None);
412    let branch_tips = repo
413        .git_overlay_branch_tips()
414        .unwrap_or_default()
415        .into_iter()
416        .map(|tip| (tip.branch.clone(), tip))
417        .collect::<HashMap<_, _>>();
418    let registry = AgentRegistry::new(repo.heddle_dir());
419    let thread_manager = ThreadManager::new(repo.heddle_dir());
420    let mut entries_by_thread: HashMap<String, Vec<AgentEntry>> = HashMap::new();
421    let mut threads_by_name: HashMap<String, Thread> = HashMap::new();
422    for entry in registry.list()? {
423        entries_by_thread
424            .entry(entry.thread.clone())
425            .or_default()
426            .push(entry);
427    }
428    for mut thread in thread_manager.list()? {
429        if thread.state == ThreadState::Abandoned
430            && repo.refs().get_thread(&thread.thread)?.is_none()
431        {
432            continue;
433        }
434        refresh_thread_freshness(repo, &mut thread)?;
435        threads_by_name.insert(thread.thread.clone(), thread);
436    }
437
438    let mut names: BTreeSet<String> = threads.into_iter().collect();
439    names.extend(current.iter().cloned());
440    names.extend(entries_by_thread.keys().cloned());
441    names.extend(threads_by_name.keys().cloned());
442    names.extend(branch_tips.keys().cloned());
443
444    let mut summaries = Vec::new();
445    for name in names {
446        let (view, coordination_status) = build_thread_view(
447            repo,
448            current.as_ref() == Some(&name),
449            name.clone(),
450            entries_by_thread.remove(&name).unwrap_or_default(),
451            threads_by_name.remove(&name),
452            branch_tips.get(&name).cloned(),
453        )?;
454        let mut summary = ThreadSummary::from_view(view, coordination_status);
455        if let Some(branch_tip) = branch_tips.get(&summary.name) {
456            summary.git_branch_tip = Some(branch_tip.git_commit.clone());
457            summary.history_imported = branch_tip.history_imported;
458        }
459        let thread = Thread {
460            id: summary.name.clone(),
461            thread: summary.name.clone(),
462            target_thread: summary.target_thread.clone(),
463            parent_thread: summary.parent_thread.clone(),
464            mode: summary
465                .thread_mode
466                .clone()
467                .unwrap_or(ThreadMode::Lightweight),
468            state: summary.thread_state.clone().unwrap_or(ThreadState::Active),
469            base_state: summary.base_state.clone().unwrap_or_default(),
470            base_root: summary.base_root.clone().unwrap_or_default(),
471            current_state: summary.current_state.clone(),
472            merged_state: None,
473            task: summary.task.clone(),
474            execution_path: summary
475                .execution_path
476                .as_ref()
477                .map(PathBuf::from)
478                .unwrap_or_else(|| repo.root().to_path_buf()),
479            materialized_path: summary.path.as_ref().map(PathBuf::from),
480            changed_paths: summary.changed_paths.clone(),
481            impact_categories: summary.impact_categories.clone(),
482            heavy_impact_paths: summary.heavy_impact_paths.clone(),
483            promotion_suggested: summary.promotion_suggested,
484            freshness: summary
485                .freshness
486                .clone()
487                .unwrap_or(ThreadFreshness::Unknown),
488            verification_summary: summary.verification_summary.clone(),
489            confidence_summary: summary.confidence_summary.clone(),
490            integration_policy_result: summary.integration_policy_result.clone(),
491            created_at: Utc::now(),
492            updated_at: Utc::now(),
493            ephemeral: None,
494            auto: summary.auto,
495            shared_target_dir: summary.shared_target_dir.as_ref().map(PathBuf::from),
496        };
497        let advice = describe_thread_advice(&thread, false, 0, false);
498        summary.thread_health = advice.thread_health;
499        summary.blockers = advice.blockers;
500        summary.recommended_action = advice.recommended_action;
501        if matches!(
502            summary.thread_state,
503            Some(ThreadState::Merged | ThreadState::Abandoned)
504        ) {
505            summary.thread_health = "clean".to_string();
506            summary.blockers.clear();
507            summary.recommended_action.clear();
508            summary.coordination_status = CoordinationStatus::Clean;
509        }
510        if let Some(branch_tip) = branch_tips.get(&summary.name)
511            && !branch_tip.history_imported
512        {
513            summary.thread_health = "tip_only".to_string();
514            summary.blockers = vec![
515                "Git branch is visible as a tip-only mirror; import its history to use history-oriented Heddle commands".to_string(),
516            ];
517            summary.recommended_action =
518                format!("heddle bridge git import --ref {}", branch_tip.branch);
519        }
520        if summary.is_current {
521            summary.operation = operation.clone();
522            summary.remote_tracking = remote_tracking.clone();
523            summary.recommended_action = primary_next_action(
524                operation.as_ref(),
525                remote_tracking.as_ref(),
526                import_hint.as_ref(),
527                Some(&summary.recommended_action),
528            );
529        }
530        summaries.push(summary);
531    }
532
533    let mut children_by_parent: HashMap<String, Vec<String>> = HashMap::new();
534    for summary in &summaries {
535        if let Some(parent) = &summary.parent_thread {
536            children_by_parent
537                .entry(parent.clone())
538                .or_default()
539                .push(summary.name.clone());
540        }
541    }
542    for summary in &mut summaries {
543        if let Some(children) = children_by_parent.remove(&summary.name) {
544            let mut children = children;
545            children.sort();
546            summary.child_threads = children;
547        }
548    }
549
550    let summaries_by_name = summaries
551        .iter()
552        .map(|summary| (summary.name.clone(), summary.clone()))
553        .collect::<HashMap<_, _>>();
554    let mut siblings_by_thread: HashMap<String, Vec<String>> = HashMap::new();
555    for summary in &summaries {
556        if let Some(parent) = &summary.parent_thread {
557            let siblings = summaries_by_name
558                .values()
559                .filter(|candidate| candidate.parent_thread.as_deref() == Some(parent.as_str()))
560                .filter(|candidate| candidate.name != summary.name)
561                .map(|candidate| candidate.name.clone())
562                .collect::<Vec<_>>();
563            siblings_by_thread.insert(summary.name.clone(), siblings);
564        }
565    }
566    for summary in &mut summaries {
567        summary.sibling_threads = siblings_by_thread.remove(&summary.name).unwrap_or_default();
568        summary.stack_depth = stack_depth(&summaries_by_name, &summary.name);
569        summary.stale_from_parent =
570            summary.parent_thread.is_some() && summary.freshness == Some(ThreadFreshness::Stale);
571        if summary.last_progress_at.is_some() {
572            summary.last_activity_at = summary.last_progress_at.clone();
573        }
574    }
575
576    summaries.sort_by(|a, b| a.name.cmp(&b.name));
577    Ok(summaries)
578}
579
580fn stack_depth(summaries_by_name: &HashMap<String, ThreadSummary>, thread: &str) -> usize {
581    let mut depth = 0usize;
582    let mut cursor = summaries_by_name
583        .get(thread)
584        .and_then(|summary| summary.parent_thread.clone());
585    while let Some(parent) = cursor {
586        depth += 1;
587        cursor = summaries_by_name
588            .get(&parent)
589            .and_then(|summary| summary.parent_thread.clone());
590    }
591    depth
592}
593
594fn build_thread_view(
595    repo: &Repository,
596    is_current: bool,
597    name: String,
598    entries: Vec<AgentEntry>,
599    thread: Option<Thread>,
600    branch_tip: Option<GitOverlayBranchTip>,
601) -> Result<(ThreadView, CoordinationStatus)> {
602    let current_state = repo.refs().get_thread(&name)?.map(|id| id.short());
603    let has_heddle_tip = current_state.is_some();
604    let active: Vec<&AgentEntry> = entries
605        .iter()
606        .filter(|entry| entry.status == AgentStatus::Active)
607        .collect();
608    let complete: Vec<&AgentEntry> = entries
609        .iter()
610        .filter(|entry| entry.status == AgentStatus::Complete)
611        .collect();
612
613    let primary = active
614        .iter()
615        .max_by_key(|entry| entry.started_at)
616        .copied()
617        .or_else(|| entries.iter().max_by_key(|entry| entry.started_at));
618    let base_state = thread
619        .as_ref()
620        .map(|thread| thread.base_state.clone())
621        .or_else(|| primary.map(|entry| entry.base_state.clone()))
622        .or(current_state.clone());
623    let base_root = thread.as_ref().map(|thread| thread.base_root.clone());
624    let runtime = ThreadRuntimeOverlay {
625        path: thread
626            .as_ref()
627            .and_then(|thread| thread.materialized_path.clone())
628            .or_else(|| primary.and_then(|entry| entry.path.clone())),
629        execution_path: thread.as_ref().map(|thread| thread.execution_path.clone()),
630        materialized_path: thread
631            .as_ref()
632            .and_then(|thread| thread.materialized_path.clone()),
633        session_id: primary.map(|entry| entry.session_id.clone()),
634        heddle_session_id: primary.and_then(|entry| entry.heddle_session_id.clone()),
635        harness: primary.and_then(|entry| entry.harness.clone()),
636        thinking_level: primary.and_then(|entry| entry.thinking_level.clone()),
637        native_actor_key: primary.and_then(|entry| entry.native_actor_key.clone()),
638        native_parent_actor_key: primary.and_then(|entry| entry.native_parent_actor_key.clone()),
639        probe_source: primary.and_then(|entry| entry.probe_source.clone()),
640        probe_confidence: primary.and_then(|entry| entry.probe_confidence),
641        usage_summary: primary.map(|entry| entry.usage_summary.clone()),
642        last_progress_at: primary.and_then(|entry| entry.last_progress_at),
643        report_flush_state: primary.and_then(|entry| entry.report_flush_state.clone()),
644        attach_reason: primary.and_then(|entry| entry.attach_reason.clone()),
645        provider: primary.and_then(|entry| entry.provider.clone()),
646        model: primary.and_then(|entry| entry.model.clone()),
647        thread_mode: thread.as_ref().map(|thread| thread.mode.clone()),
648        thread_state: thread.as_ref().map(|thread| thread.state.clone()),
649    };
650    let thread_record = thread.as_ref().map(|thread| thread.to_record());
651    let thread_state_for_status = thread_record.as_ref().map(|thread| thread.state.clone());
652    let coordination_status = if matches!(
653        thread_state_for_status,
654        Some(ThreadState::Merged | ThreadState::Abandoned)
655    ) {
656        CoordinationStatus::Clean
657    } else if thread_state_for_status == Some(ThreadState::Blocked) {
658        CoordinationStatus::Blocked
659    } else if thread_state_for_status == Some(ThreadState::Ready) {
660        CoordinationStatus::MergeReady
661    } else if active.len() > 1 {
662        CoordinationStatus::Blocked
663    } else if !active.is_empty()
664        && complete
665            .iter()
666            .any(|entry| entry.base_state != active[0].base_state)
667    {
668        CoordinationStatus::Diverged
669    } else if !complete.is_empty() {
670        CoordinationStatus::MergeReady
671    } else if base_state.is_some() && current_state.is_some() && base_state != current_state {
672        CoordinationStatus::Ahead
673    } else {
674        CoordinationStatus::Clean
675    };
676
677    let view = match thread {
678        Some(mut thread) => {
679            thread.current_state = current_state;
680            thread.to_view(runtime, is_current)
681        }
682        None => ThreadView::from_record(
683            repo::ThreadRecord {
684                id: name.clone(),
685                thread: name.clone(),
686                target_thread: None,
687                parent_thread: None,
688                mode: ThreadMode::Lightweight,
689                state: ThreadState::Active,
690                base_state: base_state.unwrap_or_default(),
691                base_root: base_root.unwrap_or_default(),
692                current_state,
693                merged_state: None,
694                task: None,
695                changed_paths: Vec::new(),
696                impact_categories: Vec::new(),
697                heavy_impact_paths: Vec::new(),
698                promotion_suggested: false,
699                freshness: ThreadFreshness::Unknown,
700                verification_summary: Default::default(),
701                confidence_summary: Default::default(),
702                integration_policy_result: Default::default(),
703                created_at: Utc::now(),
704                updated_at: Utc::now(),
705                ephemeral: None,
706                auto: false,
707                shared_target_dir: None,
708            },
709            runtime,
710            is_current,
711        ),
712    };
713
714    if let Some(branch_tip) = branch_tip
715        && !has_heddle_tip
716        && view.record.current_state.is_none()
717    {
718        let mut record = view.record.clone();
719        record.current_state = None;
720        let mut runtime = view.runtime.clone();
721        if runtime.attach_reason.is_none() {
722            runtime.attach_reason = Some(format!(
723                "auto-adopted Git branch tip {}",
724                branch_tip.git_commit
725            ));
726        }
727        return Ok((
728            ThreadView::from_record(record, runtime, is_current),
729            coordination_status,
730        ));
731    }
732
733    Ok((view, coordination_status))
734}
735
736pub fn find_thread_summary(repo: &Repository, name: &str) -> Result<Option<ThreadSummary>> {
737    Ok(collect_thread_summaries(repo)?
738        .into_iter()
739        .find(|summary| summary.name == name))
740}
741
742/// Fast single-thread summary. Skips the full `collect_thread_summaries`
743/// walk (which reads every thread record, every agent entry, every git
744/// branch tip — 45ms on a 69-thread repo) in favor of reading just the
745/// one thread we care about.
746///
747/// Trade-offs vs. the full path:
748/// - `child_threads` / `sibling_threads` are always empty. Computing
749///   them needs a global parent-thread scan; callers that display these
750///   relations should route through `find_thread_summary` instead.
751/// - `git_branch_tip` and `history_imported` are not populated for
752///   the same reason — discovering them needs the full gix branch walk.
753///   `tip_only` thread_health is therefore not surfaced; the import-
754///   hint line on the surrounding render already nudges the user.
755///
756/// Used by `heddle status` on the default text path where none of the
757/// above fields are rendered. JSON and `-v` still go through the
758/// full walk because those surfaces actually display the relations.
759pub fn find_thread_summary_single(repo: &Repository, name: &str) -> Result<Option<ThreadSummary>> {
760    let current = repo.current_lane()?;
761    let is_current = current.as_deref() == Some(name);
762    // Just this thread's record.
763    let thread_manager = ThreadManager::new(repo.heddle_dir());
764    let mut thread_record = thread_manager.find_by_thread(name)?;
765    if let Some(thread) = thread_record.as_mut() {
766        refresh_thread_freshness(repo, thread)?;
767    }
768    // Just this thread's agent entries.
769    let registry = AgentRegistry::new(repo.heddle_dir());
770    let entries: Vec<AgentEntry> = registry
771        .list()?
772        .into_iter()
773        .filter(|entry| entry.thread == name)
774        .collect();
775
776    let (view, coordination_status) = build_thread_view(
777        repo,
778        is_current,
779        name.to_string(),
780        entries,
781        thread_record,
782        None, // skip branch_tip lookup (would require full gix walk)
783    )?;
784    let mut summary = ThreadSummary::from_view(view, coordination_status);
785
786    // Re-run the per-thread fixups that `collect_thread_summaries` applies.
787    let thread_for_advice = Thread {
788        id: summary.name.clone(),
789        thread: summary.name.clone(),
790        target_thread: summary.target_thread.clone(),
791        parent_thread: summary.parent_thread.clone(),
792        mode: summary
793            .thread_mode
794            .clone()
795            .unwrap_or(ThreadMode::Lightweight),
796        state: summary.thread_state.clone().unwrap_or(ThreadState::Active),
797        base_state: summary.base_state.clone().unwrap_or_default(),
798        base_root: summary.base_root.clone().unwrap_or_default(),
799        current_state: summary.current_state.clone(),
800        merged_state: None,
801        task: summary.task.clone(),
802        execution_path: summary
803            .execution_path
804            .as_ref()
805            .map(PathBuf::from)
806            .unwrap_or_else(|| repo.root().to_path_buf()),
807        materialized_path: summary.path.as_ref().map(PathBuf::from),
808        changed_paths: summary.changed_paths.clone(),
809        impact_categories: summary.impact_categories.clone(),
810        heavy_impact_paths: summary.heavy_impact_paths.clone(),
811        promotion_suggested: summary.promotion_suggested,
812        freshness: summary
813            .freshness
814            .clone()
815            .unwrap_or(ThreadFreshness::Unknown),
816        verification_summary: summary.verification_summary.clone(),
817        confidence_summary: summary.confidence_summary.clone(),
818        integration_policy_result: summary.integration_policy_result.clone(),
819        created_at: Utc::now(),
820        updated_at: Utc::now(),
821        ephemeral: None,
822        auto: summary.auto,
823        shared_target_dir: summary.shared_target_dir.as_ref().map(PathBuf::from),
824    };
825    let advice = describe_thread_advice(&thread_for_advice, false, 0, false);
826    summary.thread_health = advice.thread_health;
827    summary.blockers = advice.blockers;
828    summary.recommended_action = advice.recommended_action;
829    if matches!(
830        summary.thread_state,
831        Some(ThreadState::Merged | ThreadState::Abandoned)
832    ) {
833        summary.thread_health = "clean".to_string();
834        summary.blockers.clear();
835        summary.recommended_action.clear();
836        summary.coordination_status = CoordinationStatus::Clean;
837    }
838    if is_current {
839        // Current-thread next-action enrichment. Same as the full path,
840        // but we skip the operation/remote_tracking/import_hint reads
841        // because the caller (status) already has those and threads
842        // through different fields anyway.
843        summary.recommended_action =
844            primary_next_action(None, None, None, Some(&summary.recommended_action));
845    }
846    Ok(Some(summary))
847}
848
849pub(crate) fn visibility_label(mode: &ThreadMode) -> &'static str {
850    match mode {
851        ThreadMode::Materialized | ThreadMode::Lightweight => "heavy",
852        ThreadMode::Virtualized => "light",
853    }
854}
855
856pub(crate) fn git_history_label(history_imported: bool) -> &'static str {
857    if history_imported {
858        "full history available"
859    } else {
860        "tip available"
861    }
862}
863
864pub(crate) fn cmd_thread_list(cli: &Cli, repo: &Repository, args: ThreadListArgs) -> Result<()> {
865    let current = repo.current_lane()?;
866    let mut summaries = collect_thread_summaries(repo)?;
867    if !args.include_auto {
868        // Always keep the current thread visible even if it's auto:
869        // hiding it from the user who is *standing in it* would be
870        // worse than the noise it adds.
871        summaries.retain(|summary| summary.is_current || !summary.auto);
872    }
873    let output = ThreadListOutput {
874        repository_capability: repo.capability_label().to_string(),
875        storage_model: repo.storage_model_label().to_string(),
876        hosted_enabled: repo.hosted_enabled(),
877        git_overlay_import_hint: repo.git_overlay_import_hint()?.map(|hint| {
878            ThreadListGitOverlayImportHintOutput {
879                current_branch: hint.current_branch,
880                missing_branch_count: hint.missing_branch_count,
881                missing_branches: hint.missing_branches,
882                recommended_command: hint.recommended_command,
883            }
884        }),
885        threads: summaries,
886        current,
887    };
888
889    if should_output_json(cli, Some(repo.config())) {
890        println!("{}", serde_json::to_string(&output)?);
891    } else if output.threads.is_empty() {
892        println!("No threads");
893    } else {
894        println!(
895            "{} {} {}",
896            style::bold("Threads"),
897            style::dim("in"),
898            output.repository_capability
899        );
900        println!(
901            "Repository mode: {} {}",
902            output.repository_capability,
903            style::dim(&format!("({})", output.storage_model))
904        );
905        if output.hosted_enabled {
906            println!("Hosted: {}", style::accent("enabled"));
907        }
908        if let Some(hint) = &output.git_overlay_import_hint {
909            println!(
910                "Git import: {} other Git branch(es) are available to import ({})",
911                hint.missing_branch_count,
912                crate::cli::render::preview_list(&hint.missing_branches, hint.missing_branch_count,)
913            );
914            println!("Next step: {}", style::bold(&hint.recommended_command));
915        }
916        render_thread_sections(&output.threads);
917    }
918
919    Ok(())
920}
921
922type ThreadSectionPredicate = fn(&ThreadSummary) -> bool;
923type ThreadSection = (&'static str, ThreadSectionPredicate);
924
925fn render_thread_sections(threads: &[ThreadSummary]) {
926    let sections: [ThreadSection; 5] = [
927        ("Current", |entry| entry.is_current),
928        ("Needs attention", thread_needs_attention),
929        ("Ready to merge", thread_ready_to_merge),
930        ("Imported Git refs", thread_is_imported_git_ref),
931        ("Other threads", |_| true),
932    ];
933
934    let mut printed = vec![false; threads.len()];
935    for (label, predicate) in sections {
936        let indexes = threads
937            .iter()
938            .enumerate()
939            .filter_map(|(index, entry)| (!printed[index] && predicate(entry)).then_some(index))
940            .collect::<Vec<_>>();
941        if indexes.is_empty() {
942            continue;
943        }
944        println!();
945        println!("{}", style::bold(label));
946        for index in indexes {
947            printed[index] = true;
948            render_thread_entry(&threads[index]);
949        }
950    }
951}
952
953fn thread_needs_attention(entry: &ThreadSummary) -> bool {
954    !entry.blockers.is_empty()
955        || entry.operation.is_some()
956        || entry.coordination_status == CoordinationStatus::Blocked
957        || entry.coordination_status == CoordinationStatus::Diverged
958}
959
960fn thread_ready_to_merge(entry: &ThreadSummary) -> bool {
961    entry.coordination_status == CoordinationStatus::MergeReady
962        || (entry.coordination_status == CoordinationStatus::Ahead
963            && entry.thread_state != Some(ThreadState::Merged)
964            && entry.target_thread.is_some())
965}
966
967fn thread_is_imported_git_ref(entry: &ThreadSummary) -> bool {
968    entry.git_branch_tip.is_some()
969        || (entry.path.is_none()
970            && entry.execution_path.is_none()
971            && entry.target_thread.is_none()
972            && entry.history_imported
973            && entry.name.starts_with("origin/"))
974}
975
976fn render_thread_entry(entry: &ThreadSummary) {
977    let prefix = if entry.is_current {
978        style::accent("*")
979    } else {
980        style::dim("-")
981    };
982    let state = entry.current_state.as_deref().unwrap_or("(no state)");
983    println!(
984        "{} {} {} {} {}",
985        prefix,
986        style::bold(&entry.name),
987        style::dim(state),
988        style::thread_state(&entry.coordination_status.to_string()),
989        style::dim(&entry.visibility)
990    );
991    if let Some(path) = &entry.path {
992        println!("    path: {}", path);
993    } else if let Some(path) = &entry.execution_path {
994        println!("    execution root: {}", path);
995    }
996    if let Some(git_branch_tip) = &entry.git_branch_tip {
997        println!(
998            "    git tip: {} {}",
999            style::dim(git_branch_tip),
1000            style::dim(&format!("({})", git_history_label(entry.history_imported)))
1001        );
1002    }
1003    if let Some(state) = &entry.thread_state {
1004        println!("    lifecycle: {}", style::thread_state(&state.to_string()));
1005    }
1006    if let Some(freshness) = &entry.freshness
1007        && *freshness != ThreadFreshness::Unknown
1008        && !matches!(
1009            entry.thread_state,
1010            Some(ThreadState::Merged | ThreadState::Abandoned)
1011        )
1012    {
1013        println!("    sync: {}", style::thread_state(&freshness.to_string()));
1014    }
1015    if let Some(operation) = &entry.operation {
1016        println!(
1017            "    in progress: {} {} ({})",
1018            style::warn(&operation.scope.to_string()),
1019            style::warn(&operation.kind.to_string()),
1020            style::dim(&operation.state)
1021        );
1022    }
1023    if let Some(remote_tracking) = &entry.remote_tracking {
1024        println!("    sync: {}", style::warn(&remote_tracking.message));
1025    }
1026    if let Some(actor) = &entry.actor
1027        && let Some(text) =
1028            crate::cli::render::actor_display(actor.provider.as_deref(), actor.model.as_deref())
1029    {
1030        println!("    actor: {text}");
1031    }
1032    if let Some(task) = &entry.task {
1033        println!("    task: {}", task);
1034    }
1035    if let Some(parent) = &entry.parent_thread {
1036        println!("    parent: {}", parent);
1037    }
1038    if !entry.child_threads.is_empty() {
1039        println!("    children: {}", entry.child_threads.join(", "));
1040    }
1041    if entry.promotion_suggested && !entry.heavy_impact_paths.is_empty() {
1042        println!(
1043            "    promotion: suggested ({})",
1044            crate::cli::render::preview_list(
1045                &entry.heavy_impact_paths,
1046                entry.heavy_impact_paths.len(),
1047            )
1048        );
1049    }
1050    if !entry.impact_categories.is_empty() {
1051        println!(
1052            "    impacts: {}",
1053            entry
1054                .impact_categories
1055                .iter()
1056                .map(ToString::to_string)
1057                .collect::<Vec<_>>()
1058                .join(", ")
1059        );
1060    }
1061    if !entry.blockers.is_empty() {
1062        println!(
1063            "    blocked by: {}",
1064            style::warn(&entry.blockers.join(" | "))
1065        );
1066    }
1067    if !entry.recommended_action.is_empty() {
1068        println!("    next step: {}", style::bold(&entry.recommended_action));
1069    }
1070}
1071
1072pub(crate) fn start_thread(repo: &Repository, args: ThreadStartArgs) -> Result<ThreadOpOutput> {
1073    let existing = find_active_thread_entry(repo, &args.name)?;
1074    if let Some(entry) = existing {
1075        if let Some(ref requested_path) = args.path {
1076            let requested = absolute_path(requested_path)?;
1077            let existing_path = entry
1078                .path
1079                .as_ref()
1080                .ok_or_else(|| anyhow!("Thread '{}' is already active", args.name))?;
1081            if *existing_path != requested {
1082                return Err(anyhow!(
1083                    "Thread '{}' already has an active reservation at '{}'. Use `heddle thread show {}` to inspect it, or release that session before starting another writer.",
1084                    args.name,
1085                    existing_path.display(),
1086                    args.name
1087                ));
1088            }
1089        }
1090
1091        let message = if let Some(path) = entry.path {
1092            format!(
1093                "Thread '{}' already has an active reservation at '{}'. Use `heddle thread show {}` to inspect it, or release that session before starting another writer.",
1094                args.name,
1095                path.display(),
1096                args.name
1097            )
1098        } else {
1099            format!(
1100                "Thread '{}' already has an active reservation. Use `heddle thread show {}` to inspect it, or release that session before starting another writer.",
1101                args.name, args.name
1102            )
1103        };
1104        return Err(anyhow!(message));
1105    }
1106
1107    let existing_thread_state = repo.refs().get_thread(&args.name)?;
1108    let base_state = match (&args.from, existing_thread_state) {
1109        (Some(spec), Some(existing)) => {
1110            let requested = repo
1111                .resolve_state(spec)?
1112                .ok_or_else(|| anyhow!("State '{}' not found", spec))?;
1113            if requested != existing {
1114                return Err(anyhow!(
1115                    "Thread '{}' is anchored at {}, but --from resolved to {}. Start a new thread name or refresh/rebase this thread before attaching another workspace.",
1116                    args.name,
1117                    existing.short(),
1118                    requested.short()
1119                ));
1120            }
1121            existing
1122        }
1123        (None, Some(existing)) => existing,
1124        (Some(spec), None) => repo
1125            .resolve_state(spec)?
1126            .ok_or_else(|| anyhow!("State '{}' not found", spec))?,
1127        (None, None) => ensure_current_state(
1128            repo,
1129            &UserConfig::load_default().unwrap_or_default(),
1130            Some(format!(
1131                "Bootstrap git-overlay before starting {}",
1132                args.name
1133            )),
1134        )?,
1135    };
1136
1137    if let Some(existing) = existing_thread_state {
1138        repo.refs()
1139            .set_thread_cas(&args.name, RefExpectation::Value(existing), &base_state)?;
1140    } else {
1141        repo.refs()
1142            .set_thread_cas(&args.name, RefExpectation::Missing, &base_state)?;
1143        repo.oplog()
1144            .record_thread_create(&args.name, &base_state, Some(&repo.op_scope()))?;
1145    }
1146
1147    let thread_mode = resolve_thread_mode(repo, &args);
1148    let path = match thread_mode {
1149        ThreadMode::Materialized => args
1150            .path
1151            .clone()
1152            .unwrap_or_else(|| default_thread_path(repo, &args.name)),
1153        ThreadMode::Lightweight => default_lightweight_thread_path(repo, &args.name),
1154        ThreadMode::Virtualized => default_virtualized_thread_path(repo, &args.name),
1155    };
1156    let abs_path = prepare_worktree_target(repo, &path)?;
1157
1158    // Item 2.1 of the heddle 6→8 plan: when starting a heavy
1159    // (materialized/lightweight) thread in a Rust workspace, redirect
1160    // the new checkout's `target/` to a workspace-shared dir so
1161    // parallel threads don't multiply cargo target trees on disk.
1162    //
1163    // We compute this *before* materialization so the heads-up
1164    // advisory below can reflect what would have happened, and we
1165    // apply the redirect *after* materialization (only the `cargo
1166    // config.toml` writer touches the checkout; the materializer
1167    // populates the rest).
1168    //
1169    // `--shared-target` in a non-Rust repo is a harmless no-op rather
1170    // than an error: automation that passes the flag unconditionally
1171    // across mixed-language repos shouldn't have to special-case
1172    // every non-cargo project. We log a debug-level note so a curious
1173    // operator can still see it landed silently.
1174    let shared_target_dir_path: Option<PathBuf> = if args.shared_target
1175        && matches!(
1176            thread_mode,
1177            ThreadMode::Materialized | ThreadMode::Lightweight
1178        ) {
1179        if shared_target::workspace_root_is_rust(repo) {
1180            Some(shared_target::shared_target_dir(repo)?)
1181        } else {
1182            tracing::debug!(
1183                repo = %repo.root().display(),
1184                "--shared-target requested in a non-Rust repo (no top-level Cargo.toml); skipping"
1185            );
1186            None
1187        }
1188    } else {
1189        None
1190    };
1191
1192    // Heads-up advisory: when starting a second-or-later materialized
1193    // thread in a Rust workspace without `--shared-target`, nudge the
1194    // user toward the flag. Doesn't fail the start; just stderr.
1195    if !args.shared_target
1196        && matches!(
1197            thread_mode,
1198            ThreadMode::Materialized | ThreadMode::Lightweight
1199        )
1200        && shared_target::should_advise_shared_target(repo)
1201    {
1202        shared_target::print_advisory(&args.name);
1203    }
1204
1205    // Track whether `write_cargo_config` actually applied the
1206    // redirect. When the user has pre-staged `.cargo/config.toml`
1207    // the writer is a no-op and we must NOT advertise a
1208    // `shared_target_dir` on the thread record — `thread show`
1209    // would otherwise lie about a redirect that isn't in effect.
1210    let mut shared_target_dir_path = shared_target_dir_path;
1211    match thread_mode {
1212        ThreadMode::Materialized | ThreadMode::Lightweight => {
1213            write_isolated_checkout(repo, &abs_path, &base_state, Some(&args.name))?;
1214            if let Some(dir) = shared_target_dir_path.as_ref() {
1215                let applied = shared_target::write_cargo_config(&abs_path, dir)?;
1216                if !applied {
1217                    tracing::info!(
1218                        thread = %args.name,
1219                        config = %abs_path.join(".cargo").join("config.toml").display(),
1220                        "existing .cargo/config.toml preserved; --shared-target redirect not applied"
1221                    );
1222                    shared_target_dir_path = None;
1223                }
1224            }
1225        }
1226        ThreadMode::Virtualized => {
1227            // Light workspaces use the daemon-owned mount by default:
1228            // `heddled` keeps the FUSE
1229            // session alive after this CLI exits and across subsequent
1230            // invocations. `--no-daemon` opts out and pins the mount to
1231            // this process (the legacy behaviour). When the daemon is
1232            // unavailable on this host (no `fusermount`, exec failed,
1233            // etc.) we silently fall back to the in-process path with
1234            // a warning so the user still gets a working mount. See
1235            // `docs/design/mount-daemon.md` § History.
1236            let ownership =
1237                mount_lifecycle::MountOwnership::from_flags(args.daemon, args.no_daemon);
1238            mount_lifecycle::establish_virtualized_mount(
1239                repo.root(),
1240                &args.name,
1241                &abs_path,
1242                ownership,
1243            )?;
1244        }
1245    }
1246
1247    let registry = AgentRegistry::new(repo.heddle_dir());
1248    let provider = args.agent_provider.clone();
1249    let model = args.agent_model.clone();
1250    let task = args.task.clone();
1251    let path_for_entry = abs_path.clone();
1252    let thread_name = args.name.clone();
1253    let current_target_thread = match repo.head_ref()? {
1254        Head::Attached { thread } => Some(thread),
1255        Head::Detached { .. } => None,
1256    };
1257    let base_short = base_state.short();
1258    let base_state_summary = repo
1259        .store()
1260        .get_state(&base_state)?
1261        .map(|state| {
1262            (
1263                state.tree.short(),
1264                summarize_verification(state.verification.as_ref()),
1265                summarize_confidence(state.confidence),
1266            )
1267        })
1268        .ok_or_else(|| anyhow!("Base state '{}' not found", base_state.short()))?;
1269    let (base_root, verification_summary, confidence_summary) = base_state_summary;
1270    let thread_manager = ThreadManager::new(repo.heddle_dir());
1271    let thread_state = Thread {
1272        id: args.name.clone(),
1273        thread: args.name.clone(),
1274        target_thread: current_target_thread.clone(),
1275        parent_thread: args.parent_thread.clone(),
1276        mode: thread_mode.clone(),
1277        state: ThreadState::Active,
1278        base_state: base_short.clone(),
1279        base_root: base_root.clone(),
1280        current_state: Some(base_short.clone()),
1281        merged_state: None,
1282        task: task.clone(),
1283        execution_path: abs_path.clone(),
1284        materialized_path: match thread_mode {
1285            ThreadMode::Materialized | ThreadMode::Lightweight => Some(abs_path.clone()),
1286            // Virtualized records the mount point as the materialized
1287            // path so `heddle thread show` reports it; it's not a
1288            // checkout, but it is the path the user `cd`s into.
1289            ThreadMode::Virtualized => Some(abs_path.clone()),
1290        },
1291        changed_paths: vec![],
1292        impact_categories: vec![],
1293        heavy_impact_paths: vec![],
1294        promotion_suggested: false,
1295        freshness: ThreadFreshness::Current,
1296        verification_summary,
1297        confidence_summary,
1298        integration_policy_result: ThreadIntegrationPolicy::default(),
1299        created_at: Utc::now(),
1300        updated_at: Utc::now(),
1301        ephemeral: None,
1302        auto: false,
1303        shared_target_dir: shared_target_dir_path.clone(),
1304    };
1305    thread_manager.save(&thread_state)?;
1306    let entry = registry.create_generated_entry_for_thread(&thread_name, |session_id| {
1307        Ok(AgentEntry {
1308            session_id: session_id.to_string(),
1309            client_instance_id: None,
1310            native_actor_key: None,
1311            native_parent_actor_key: None,
1312            native_instance_key: None,
1313            heddle_session_id: None,
1314            thread_id: Some(thread_name.clone()),
1315            thread: thread_name.clone(),
1316            pid: Some(process::id()),
1317            boot_id: current_boot_id(),
1318            liveness_path: Some(
1319                repo.heddle_dir()
1320                    .join("agents")
1321                    .join(format!("{session_id}.live")),
1322            ),
1323            heartbeat_at: Some(Utc::now()),
1324            anchor_state: Some(base_state.to_string_full()),
1325            anchor_root: Some(base_root.clone()),
1326            reservation_token: Some(objects::store::generate_agent_id()),
1327            path: match thread_mode {
1328                ThreadMode::Materialized | ThreadMode::Lightweight | ThreadMode::Virtualized => {
1329                    Some(path_for_entry.clone())
1330                }
1331            },
1332            base_state: base_short.clone(),
1333            started_at: Utc::now(),
1334            provider: provider.clone(),
1335            model: model.clone(),
1336            harness: None,
1337            thinking_level: None,
1338            usage_summary: AgentUsageSummary::default(),
1339            last_progress_at: None,
1340            report_flush_state: None,
1341            attach_reason: Some(format!(
1342                "actor {session_id} was created when thread {} started",
1343                thread_name
1344            )),
1345            attach_precedence: vec!["thread-start".to_string()],
1346            winning_attach_rule: Some("thread-start".to_string()),
1347            probe_source: Some("explicit_payload".to_string()),
1348            probe_confidence: Some(1.0),
1349            status: AgentStatus::Active,
1350            completed_at: None,
1351            context_queries: vec![],
1352        })
1353    })?;
1354
1355    let summary = find_thread_summary(repo, &args.name)?;
1356    let message = match thread_mode {
1357        ThreadMode::Lightweight | ThreadMode::Materialized => {
1358            format!(
1359                "Started heavy thread '{}' at '{}'",
1360                args.name,
1361                abs_path.display()
1362            )
1363        }
1364        ThreadMode::Virtualized => {
1365            // Print the mount path so the user knows where to `cd`.
1366            // The trailing newline before the next status line keeps
1367            // the path easy to copy-paste from terminal output.
1368            format!(
1369                "Started light thread '{}' mounted at '{}'",
1370                args.name,
1371                abs_path.display()
1372            )
1373        }
1374    };
1375
1376    Ok(ThreadOpOutput {
1377        name: args.name,
1378        message,
1379        path: summary.as_ref().and_then(|thread| thread.path.clone()),
1380        execution_path: Some(abs_path.display().to_string()),
1381        thread: summary.map(|mut thread| {
1382            thread.session_id = Some(entry.session_id.clone());
1383            thread
1384        }),
1385    })
1386}
1387
1388fn resolve_thread_mode(repo: &Repository, args: &ThreadStartArgs) -> ThreadMode {
1389    if args.path.is_some() {
1390        // An explicit `--path` means "put the heavy checkout here".
1391        // Light workspaces stay Heddle-managed so a mount never shadows
1392        // a user-named directory.
1393        return ThreadMode::Materialized;
1394    }
1395
1396    match args.workspace {
1397        WorkspaceModeArg::Heavy => ThreadMode::Lightweight,
1398        WorkspaceModeArg::Light => ThreadMode::Virtualized,
1399        WorkspaceModeArg::Auto => match resolve_auto_workspace_default(repo, args) {
1400            UserThreadWorkspaceMode::Heavy => ThreadMode::Lightweight,
1401            UserThreadWorkspaceMode::Light => ThreadMode::Virtualized,
1402            UserThreadWorkspaceMode::Auto => ThreadMode::Lightweight,
1403        },
1404    }
1405}
1406
1407fn resolve_auto_workspace_default(
1408    _repo: &Repository,
1409    args: &ThreadStartArgs,
1410) -> UserThreadWorkspaceMode {
1411    let user_config = UserConfig::load_default().unwrap_or_default();
1412    if args.parent_thread.is_some() || args.automated {
1413        user_config
1414            .worktree
1415            .thread_workspace
1416            .delegated_default
1417            .unwrap_or(UserThreadWorkspaceMode::Heavy)
1418    } else {
1419        user_config.worktree.thread_workspace.top_level_default
1420    }
1421}
1422
1423pub(crate) fn cmd_thread_create(
1424    cli: &Cli,
1425    repo: &Repository,
1426    name: String,
1427    ephemeral: bool,
1428    ttl_secs: Option<u32>,
1429) -> Result<()> {
1430    // `ephemeral` / `ttl_secs` are part of main's evolved
1431    // ephemeral-threads API; not yet plumbed into the Thread record
1432    // here. TODO: thread these through to a ThreadLifecycle field
1433    // when the ephemeral-threads work lands.
1434    let _ = (ephemeral, ttl_secs);
1435    // Codex's body: auto-bootstrap a current state when there isn't
1436    // one — needed in fresh git-overlay repos where `heddle init`
1437    // hasn't produced a snapshot yet.
1438    let current = ensure_current_state(
1439        repo,
1440        &UserConfig::load_default().unwrap_or_default(),
1441        Some(format!(
1442            "Bootstrap git-overlay before creating thread {}",
1443            name
1444        )),
1445    )?;
1446
1447    repo.refs()
1448        .set_thread_cas(&name, RefExpectation::Missing, &current)?;
1449    repo.oplog()
1450        .record_thread_create(&name, &current, Some(&repo.op_scope()))?;
1451
1452    // Persist a Thread record so subsequent commands that go through
1453    // `ThreadManager::load` (delegate, ship, integration policy,
1454    // `thread show`'s record path) can find it. Without this the ref
1455    // exists but the record file is missing and any `manager.load(name)`
1456    // returns `None`, surfacing as `Thread '<name>' not found` even
1457    // though `thread switch` (which only consults refs) works.
1458    //
1459    // `create` differs from `start` in that no worktree is materialized:
1460    // we record a `Lightweight` thread with an empty `execution_path`
1461    // (and `materialized_path: None`) — the same shape `Thread::from_record`
1462    // hydrates when no workspace overlay exists. Consumers that key off
1463    // `materialized_path`/`execution_path` to drive an actual checkout
1464    // already treat this as "no dedicated worktree".
1465    let base_short = current.short();
1466    let (base_root, verification_summary, confidence_summary) = repo
1467        .store()
1468        .get_state(&current)?
1469        .map(|state| {
1470            (
1471                state.tree.short(),
1472                summarize_verification(state.verification.as_ref()),
1473                summarize_confidence(state.confidence),
1474            )
1475        })
1476        .ok_or_else(|| anyhow!("Base state '{}' not found", base_short))?;
1477    let target_thread = match repo.head_ref()? {
1478        Head::Attached { thread } => Some(thread),
1479        Head::Detached { .. } => None,
1480    };
1481    let thread_manager = ThreadManager::new(repo.heddle_dir());
1482    let now = Utc::now();
1483    let thread_state = Thread {
1484        id: name.clone(),
1485        thread: name.clone(),
1486        target_thread,
1487        parent_thread: None,
1488        mode: ThreadMode::Lightweight,
1489        state: ThreadState::Active,
1490        base_state: base_short.clone(),
1491        base_root,
1492        current_state: Some(base_short.clone()),
1493        merged_state: None,
1494        task: None,
1495        execution_path: PathBuf::new(),
1496        materialized_path: None,
1497        changed_paths: vec![],
1498        impact_categories: vec![],
1499        heavy_impact_paths: vec![],
1500        promotion_suggested: false,
1501        freshness: ThreadFreshness::Current,
1502        verification_summary,
1503        confidence_summary,
1504        integration_policy_result: ThreadIntegrationPolicy::default(),
1505        created_at: now,
1506        updated_at: now,
1507        ephemeral: if ephemeral {
1508            Some(repo::EphemeralMarker::new(ttl_secs.unwrap_or(24 * 3600)))
1509        } else {
1510            None
1511        },
1512        // `heddle thread create` is the explicit user verb — never
1513        // an auto-thread, even when run inside a harness session.
1514        auto: false,
1515        // `heddle thread create` doesn't materialize a checkout, so
1516        // there's nowhere to redirect cargo's `target/` to. Threads
1517        // promoted to materialized later can opt in then.
1518        shared_target_dir: None,
1519    };
1520    thread_manager.save(&thread_state)?;
1521
1522    let output = ThreadOpOutput {
1523        name: name.clone(),
1524        message: format!("Created thread '{}' at {}", name, current.short()),
1525        path: None,
1526        execution_path: None,
1527        thread: find_thread_summary(repo, &name)?,
1528    };
1529
1530    render_thread_op(cli, output)
1531}
1532
1533pub(crate) fn cmd_thread_switch(cli: &Cli, repo: &Repository, name: String) -> Result<()> {
1534    let state = repo
1535        .refs()
1536        .get_thread(&name)?
1537        .ok_or_else(|| anyhow!("Thread not found: {}", name))?;
1538
1539    // "Invisible thread directories" rule: switching to a thread that has
1540    // its *own* dedicated worktree (the one `heddle start --workspace
1541    // private|virtualized` recorded under `.run-heddle-threads/<name>/`)
1542    // is a metadata-only operation. The on-disk worktree at the
1543    // recorded path is already X's worktree — it was set up by `start`
1544    // and is kept in sync by the metadata-driven merge/rebase/goto/ship
1545    // dispatcher (see `Repository::active_worktree_path`). The operator's
1546    // CWD must stay untouched so `thread switch X` from `$ROOT` does NOT
1547    // overwrite `$ROOT`'s files with X's tree.
1548    //
1549    // For threads without a dedicated worktree (created via
1550    // `thread create`, or threads whose recorded path collapses onto the
1551    // repo root), fall back to the legacy `goto`-based switch so the
1552    // traditional create-then-switch-then-snapshot workflow keeps working
1553    // — those threads share the repo root and the user expects the
1554    // worktree to flip to the target tree.
1555    let manager = ThreadManager::new(repo.heddle_dir());
1556    let dedicated_worktree = manager
1557        .find_by_thread(&name)?
1558        .map(|thread| thread.execution_path)
1559        .filter(|path| !path.as_os_str().is_empty() && path != repo.root());
1560
1561    if let Some(path) = dedicated_worktree {
1562        if !path.exists() {
1563            // Forgiving recovery: the recorded worktree was deleted out
1564            // of band (manual `rm -rf`, partial cleanup, etc). Rebuild
1565            // it from `current_state` rather than erroring — `current_state`
1566            // is the canonical source of truth for the thread's content,
1567            // and there's no obvious recovery command to point the user
1568            // at. Anything the worktree held that wasn't snapshotted is
1569            // already gone, so re-materializing just restores the
1570            // last-known good state.
1571            write_isolated_checkout(repo, &path, &state, Some(&name))?;
1572        }
1573        // Metadata-only: the dedicated worktree is already correct, so
1574        // we only need to flip HEAD. Importantly: this does NOT touch
1575        // CWD. Intentional raw `write_head` (not `fast_forward_attached`):
1576        // we're attaching to a *new* thread.
1577        repo.refs().write_head(&Head::Attached {
1578            thread: name.clone(),
1579        })?;
1580    } else {
1581        // Legacy shared-worktree path: materialize the target tree at
1582        // CWD and reattach HEAD to the thread. Intentional raw `goto`:
1583        // `fast_forward_attached` would re-attach to the previously
1584        // attached thread, which is the wrong behavior here.
1585        repo.goto(&state)?;
1586        repo.refs().write_head(&Head::Attached {
1587            thread: name.clone(),
1588        })?;
1589    }
1590
1591    let summary = find_thread_summary(repo, &name)?;
1592    let mut message = format!("Switched to thread '{}'", name);
1593    if let Some(thread) = &summary
1594        && thread.coordination_status != CoordinationStatus::Clean
1595    {
1596        message.push_str(&format!(" [{}]", thread.coordination_status));
1597    }
1598
1599    render_thread_op(
1600        cli,
1601        ThreadOpOutput {
1602            name,
1603            message,
1604            path: summary.as_ref().and_then(|thread| thread.path.clone()),
1605            execution_path: summary
1606                .as_ref()
1607                .and_then(|thread| thread.execution_path.clone()),
1608            thread: summary,
1609        },
1610    )
1611}
1612
1613pub fn cmd_thread_show(cli: &Cli, repo: &Repository, name: Option<String>) -> Result<()> {
1614    let name = super::thread_cmd::resolve_thread_name_or_current(repo, name)?;
1615
1616    let summary =
1617        find_thread_summary(repo, &name)?.ok_or_else(|| anyhow!("Thread not found: {}", name))?;
1618
1619    show_thread_summary(cli, repo, &summary)
1620}
1621
1622pub(crate) fn show_thread_summary(
1623    cli: &Cli,
1624    repo: &Repository,
1625    summary: &ThreadSummary,
1626) -> Result<()> {
1627    if should_output_json(cli, Some(repo.config())) {
1628        println!("{}", serde_json::to_string(summary)?);
1629    } else {
1630        println!(
1631            "Repository mode: {} ({})",
1632            repo.capability_label(),
1633            repo.storage_model_label()
1634        );
1635        if repo.hosted_enabled() {
1636            println!("Hosted: enabled");
1637        }
1638        if let Some(operation) = &summary.operation {
1639            println!(
1640                "In progress: {} {} ({})",
1641                operation.scope, operation.kind, operation.state
1642            );
1643        }
1644        if let Some(remote_tracking) = &summary.remote_tracking {
1645            println!("Remote drift: {}", remote_tracking.message);
1646        }
1647        println!();
1648        println!("Thread: {}", summary.name);
1649        println!("Status: {}", summary.coordination_status);
1650        if let Some(base) = &summary.base_state {
1651            println!("Base: {}", base);
1652        }
1653        if let Some(base_root) = &summary.base_root {
1654            println!("Base root: {}", base_root);
1655        }
1656        if let Some(current) = &summary.current_state {
1657            println!("Current: {}", current);
1658        }
1659        if let Some(git_branch_tip) = &summary.git_branch_tip {
1660            println!("Git tip: {}", git_branch_tip);
1661            println!("History: {}", git_history_label(summary.history_imported));
1662        }
1663        if let Some(path) = &summary.path {
1664            println!("Path: {}", path);
1665        } else if let Some(path) = &summary.execution_path {
1666            println!("Execution root: {}", path);
1667        }
1668        println!("Workspace: {}", summary.visibility);
1669        if let Some(shared) = &summary.shared_target_dir {
1670            println!("Shared cargo target: {}", shared);
1671        }
1672        if let Some(state) = &summary.thread_state {
1673            println!("Lifecycle: {}", state);
1674        }
1675        if let Some(freshness) = &summary.freshness
1676            && *freshness != ThreadFreshness::Unknown
1677        {
1678            println!("Sync: {}", freshness);
1679        }
1680        if let Some(target) = &summary.target_thread {
1681            println!("Target thread: {}", target);
1682        }
1683        if let Some(parent) = &summary.parent_thread {
1684            println!("Parent thread: {}", parent);
1685        }
1686        if !summary.child_threads.is_empty() {
1687            println!("Child threads: {}", summary.child_threads.join(", "));
1688        }
1689        if !summary.sibling_threads.is_empty() {
1690            println!("Sibling threads: {}", summary.sibling_threads.join(", "));
1691        }
1692        if summary.stack_depth > 0 {
1693            println!("Stack depth: {}", summary.stack_depth);
1694        }
1695        if summary.stale_from_parent {
1696            println!("Parent drift: parent moved since this thread last refreshed");
1697        }
1698        if let Some(actor) = &summary.actor
1699            && let Some(text) =
1700                crate::cli::render::actor_display(actor.provider.as_deref(), actor.model.as_deref())
1701        {
1702            println!("Actor: {text}");
1703        }
1704        if let Some(session_id) = &summary.session_id {
1705            println!("Session: {}", session_id);
1706        }
1707        if let Some(session) = &summary.heddle_session_id {
1708            println!("Heddle session: {}", session);
1709        }
1710        if let Some(harness) = &summary.harness {
1711            println!("Harness: {}", harness);
1712        }
1713        if let Some(thinking_level) = &summary.thinking_level {
1714            println!("Thinking: {}", thinking_level);
1715        }
1716        if let Some(last_progress_at) = &summary.last_progress_at {
1717            println!("Last progress: {}", last_progress_at);
1718        }
1719        if let Some(last_activity_at) = &summary.last_activity_at {
1720            println!("Last activity: {}", last_activity_at);
1721        }
1722        if let Some(report_flush_state) = &summary.report_flush_state {
1723            println!("Report flush: {}", report_flush_state);
1724        }
1725        if let Some(attach_reason) = &summary.attach_reason {
1726            println!("Attach: {}", attach_reason);
1727        }
1728        if let Some(usage_summary) = &summary.usage_summary {
1729            let mut parts = Vec::new();
1730            if let Some(input) = usage_summary.input_tokens {
1731                parts.push(format!("input {}", input));
1732            }
1733            if let Some(output) = usage_summary.output_tokens {
1734                parts.push(format!("output {}", output));
1735            }
1736            if let Some(reasoning) = usage_summary.reasoning_tokens {
1737                parts.push(format!("reasoning {}", reasoning));
1738            }
1739            if let Some(tool_calls) = usage_summary.tool_calls {
1740                parts.push(format!("tools {}", tool_calls));
1741            }
1742            if let Some(cost) = usage_summary.cost_micros_usd {
1743                parts.push(format!("cost {}uUSD", cost));
1744            }
1745            if !parts.is_empty() {
1746                println!("Usage: {}", parts.join(" · "));
1747            }
1748        }
1749        if let Some(task) = &summary.task {
1750            println!("Task: {}", task);
1751        }
1752        let captures = collect_thread_captures(repo, &summary.name, 5).unwrap_or_default();
1753        if !captures.is_empty() {
1754            println!();
1755            println!("{}", style::section("Last 5 captures"));
1756            for capture in captures {
1757                println!(
1758                    "  {} {}",
1759                    style::accent(&capture.change_id),
1760                    capture.message
1761                );
1762            }
1763        }
1764        if summary.promotion_suggested && !summary.heavy_impact_paths.is_empty() {
1765            println!(
1766                "Promotion suggested: {}",
1767                crate::cli::render::preview_list(
1768                    &summary.heavy_impact_paths,
1769                    summary.heavy_impact_paths.len(),
1770                )
1771            );
1772        }
1773        if !summary.impact_categories.is_empty() {
1774            println!(
1775                "Impact categories: {}",
1776                summary
1777                    .impact_categories
1778                    .iter()
1779                    .map(ToString::to_string)
1780                    .collect::<Vec<_>>()
1781                    .join(", ")
1782            );
1783        }
1784        if !summary.blockers.is_empty() {
1785            println!("Blocked by: {}", summary.blockers.join(" | "));
1786        }
1787        if !summary.recommended_action.is_empty() {
1788            println!("Next step: {}", summary.recommended_action);
1789        }
1790    }
1791
1792    Ok(())
1793}
1794
1795pub(crate) fn cmd_thread_delete(cli: &Cli, repo: &Repository, name: String) -> Result<()> {
1796    if let Head::Attached { thread } = repo.head_ref()?
1797        && thread == name
1798    {
1799        return Err(anyhow!(
1800            "Cannot delete current thread. Switch to another thread first."
1801        ));
1802    }
1803
1804    let state = repo
1805        .refs()
1806        .delete_thread(&name)?
1807        .ok_or_else(|| anyhow!("Thread not found: {}", name))?;
1808
1809    repo.oplog()
1810        .record_thread_delete(&name, &state, Some(&repo.op_scope()))?;
1811
1812    let output = ThreadOpOutput {
1813        name: name.clone(),
1814        message: format!("Deleted thread '{}'", name),
1815        path: None,
1816        execution_path: None,
1817        thread: None,
1818    };
1819
1820    render_thread_op(cli, output)
1821}
1822
1823pub(crate) fn cmd_thread_rename(
1824    cli: &Cli,
1825    repo: &Repository,
1826    old: String,
1827    new: String,
1828) -> Result<()> {
1829    let state = repo
1830        .refs()
1831        .get_thread(&old)?
1832        .ok_or_else(|| anyhow!("Thread not found: {}", old))?;
1833
1834    let mut updates = vec![
1835        RefUpdate::Thread {
1836            name: new.clone(),
1837            expected: RefExpectation::Missing,
1838            new: Some(state),
1839        },
1840        RefUpdate::Thread {
1841            name: old.clone(),
1842            expected: RefExpectation::Value(state),
1843            new: None,
1844        },
1845    ];
1846
1847    if let Head::Attached { thread } = repo.head_ref()?
1848        && thread == old
1849    {
1850        updates.push(RefUpdate::Head {
1851            expected: RefExpectation::Value(Head::Attached {
1852                thread: old.clone(),
1853            }),
1854            new: Head::Attached {
1855                thread: new.clone(),
1856            },
1857        });
1858    }
1859
1860    repo.refs().update_refs(&updates)?;
1861    repo.oplog()
1862        .record_thread_rename(&old, &new, &state, Some(&repo.op_scope()))?;
1863
1864    let output = ThreadOpOutput {
1865        name: new.clone(),
1866        message: format!("Renamed thread '{}' to '{}'", old, new),
1867        path: None,
1868        execution_path: None,
1869        thread: find_thread_summary(repo, &new)?,
1870    };
1871
1872    render_thread_op(cli, output)
1873}
1874
1875fn render_thread_op(cli: &Cli, output: ThreadOpOutput) -> Result<()> {
1876    if should_output_json(cli, None) {
1877        println!("{}", serde_json::to_string(&output)?);
1878    } else {
1879        println!("{}", style::accent(&output.message));
1880        if let Some(thread) = &output.thread {
1881            if let Some(path) = &thread.path {
1882                println!("Path: {}", style::dim(path));
1883                // The CLI itself can't change the parent shell's cwd. Print a
1884                // copy-pasteable `cd` hint so the next manual step is
1885                // obvious; shell wrappers can prefer `heddle start <name>
1886                // --print-cd-path` to capture the path directly.
1887                println!("Run this to switch shells:");
1888                println!("    cd {}", style::accent(&crate::cli::render::shell_quote(path)));
1889            } else if let Some(path) = &thread.execution_path {
1890                println!("Execution root: {}", style::dim(path));
1891            }
1892            if !thread.recommended_action.is_empty() {
1893                println!("Next step: {}", style::bold(&thread.recommended_action));
1894            }
1895        }
1896    }
1897    Ok(())
1898}
1899
1900fn default_thread_path(repo: &Repository, name: &str) -> PathBuf {
1901    let workspace_root = shared_workspace_root(repo);
1902    let repo_name = workspace_root
1903        .file_name()
1904        .and_then(|name| name.to_str())
1905        .filter(|name| !name.is_empty())
1906        .unwrap_or("heddle");
1907    let parent = workspace_root
1908        .parent()
1909        .map(|path| path.to_path_buf())
1910        .unwrap_or_else(|| workspace_root.to_path_buf());
1911    parent.join(format!("{repo_name}-{}", sanitize_name(name)))
1912}
1913
1914fn default_lightweight_thread_path(repo: &Repository, name: &str) -> PathBuf {
1915    let workspace_root = shared_workspace_root(repo);
1916    let repo_name = workspace_root
1917        .file_name()
1918        .and_then(|name| name.to_str())
1919        .filter(|name| !name.is_empty())
1920        .unwrap_or("heddle");
1921    let parent = workspace_root
1922        .parent()
1923        .map(|path| path.to_path_buf())
1924        .unwrap_or_else(|| workspace_root.to_path_buf());
1925    parent
1926        .join(format!(".{repo_name}-heddle-threads"))
1927        .join(sanitize_name(name))
1928        .join("root")
1929}
1930
1931/// Mount-point path for a virtualized thread. Sibling to the
1932/// lightweight checkout path so a single repo can host both kinds
1933/// of threads side-by-side without colliding.
1934///
1935/// Template: `<repo_parent>/.<repo_name>-heddle-mounts/<sanitized_name>/`
1936fn default_virtualized_thread_path(repo: &Repository, name: &str) -> PathBuf {
1937    let workspace_root = shared_workspace_root(repo);
1938    let repo_name = workspace_root
1939        .file_name()
1940        .and_then(|name| name.to_str())
1941        .filter(|name| !name.is_empty())
1942        .unwrap_or("heddle");
1943    let parent = workspace_root
1944        .parent()
1945        .map(|path| path.to_path_buf())
1946        .unwrap_or_else(|| workspace_root.to_path_buf());
1947    mount_lifecycle::default_virtualized_mount_path(&parent, repo_name, &sanitize_name(name))
1948}
1949
1950fn shared_workspace_root(repo: &Repository) -> &std::path::Path {
1951    repo.heddle_dir().parent().unwrap_or_else(|| repo.root())
1952}
1953
1954fn sanitize_name(name: &str) -> String {
1955    let mut out = String::new();
1956    let mut last_dash = false;
1957    for ch in name.chars() {
1958        let keep = ch.is_ascii_alphanumeric();
1959        if keep {
1960            out.push(ch.to_ascii_lowercase());
1961            last_dash = false;
1962        } else if !last_dash {
1963            out.push('-');
1964            last_dash = true;
1965        }
1966    }
1967    out.trim_matches('-').to_string()
1968}
1969
1970fn absolute_path(path: &std::path::Path) -> Result<PathBuf> {
1971    if path.is_absolute() {
1972        Ok(path.to_path_buf())
1973    } else {
1974        Ok(std::env::current_dir()?.join(path))
1975    }
1976}
1977
1978/// Return the most recently started *active* `AgentEntry` for `thread`, if
1979/// any. Used by `heddle status` to surface the actor for a thread, and
1980/// (since the Phase-D demo work) by `heddle capture` to inherit the
1981/// thread's actor as the captured state's `attribution.agent` — without
1982/// it, every state on an agent thread shows `Principal: Unknown`.
1983pub(crate) fn find_active_thread_entry(
1984    repo: &Repository,
1985    thread: &str,
1986) -> Result<Option<AgentEntry>> {
1987    let registry = AgentRegistry::new(repo.heddle_dir());
1988    Ok(registry
1989        .list()?
1990        .into_iter()
1991        .filter(|entry| entry.thread == thread && entry.status == AgentStatus::Active)
1992        .max_by_key(|entry| entry.started_at))
1993}