1use 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 pub auto: bool,
117 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 #[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 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
269fn 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
366fn 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
742pub 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 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 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, )?;
784 let mut summary = ThreadSummary::from_view(view, coordination_status);
785
786 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 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 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 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 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 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 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 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 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 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 let _ = (ephemeral, ttl_secs);
1435 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, ¤t)?;
1449 repo.oplog()
1450 .record_thread_create(&name, ¤t, Some(&repo.op_scope()))?;
1451
1452 let base_short = current.short();
1466 let (base_root, verification_summary, confidence_summary) = repo
1467 .store()
1468 .get_state(¤t)?
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 auto: false,
1515 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 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 write_isolated_checkout(repo, &path, &state, Some(&name))?;
1572 }
1573 repo.refs().write_head(&Head::Attached {
1578 thread: name.clone(),
1579 })?;
1580 } else {
1581 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 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
1931fn 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
1978pub(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}