Skip to main content

cli/harness/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    fs::{self, OpenOptions},
5    io::{BufRead, BufReader, BufWriter, Write},
6    path::{Path, PathBuf},
7};
8
9use anyhow::{Result, anyhow};
10use base64::Engine as _;
11use chrono::Utc;
12use objects::{
13    fs_atomic::write_file_atomic,
14    object::{DiffKind, Session, Tree},
15    store::{AgentEntry, AgentRegistry, AgentStatus, AgentUsageSummary},
16};
17use proto::{
18    HarnessIdentity, ProgressCheckpoint, SessionDiffSummary, SessionReportEnvelope,
19    TranscriptAttachmentRef, UsageTotals, WorktreeChangeBaseline,
20};
21use refs::Head;
22use repo::{
23    Repository, SessionManager, Thread, ThreadFreshness, ThreadIntegrationPolicy, ThreadManager,
24    ThreadMode, ThreadState,
25};
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28
29mod claude_hook;
30mod probe;
31
32use self::probe::{HarnessProbeInput, HarnessProbeResult, probe_harness_actor};
33use crate::{
34    cli::{
35        Cli,
36        commands::{
37            snapshot::{summarize_confidence, summarize_verification},
38            worktree_cmd::helpers::{prepare_worktree_target, write_isolated_checkout},
39        },
40        worktree_status_options,
41    },
42    config::{
43        HarnessMode, HarnessTranscriptMode, HarnessTransport, UserConfig, UserHarnessOverride,
44        UserHarnessRootThreadPolicy, UserHarnessSubagentThreadPolicy, UserThreadWorkspaceMode,
45    },
46};
47
48pub fn cmd_harness_bridge(cli: &Cli) -> Result<()> {
49    let repo = Repository::open(cli.repo.as_ref().unwrap_or(&std::env::current_dir()?))?;
50    let user_config = UserConfig::load_default().unwrap_or_default();
51    let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
52
53    let stdin = std::io::stdin();
54    let stdout = std::io::stdout();
55    let reader = BufReader::new(stdin.lock());
56    let mut writer = BufWriter::new(stdout.lock());
57
58    for line in reader.lines() {
59        let line = line?;
60        if line.trim().is_empty() {
61            continue;
62        }
63        let response = match serde_json::from_str::<BridgeRequest>(&line) {
64            Ok(request) => runtime.handle_request(request),
65            Err(err) => BridgeResponse::error(
66                None,
67                "invalid_request",
68                format!("failed to parse request: {err}"),
69            ),
70        };
71        serde_json::to_writer(&mut writer, &response)?;
72        writer.write_all(b"\n")?;
73        writer.flush()?;
74    }
75
76    Ok(())
77}
78
79pub(crate) fn relay_harness_event(
80    repo: &Repository,
81    user_config: &UserConfig,
82    harness: &str,
83    event: &str,
84    payload: &str,
85) -> Result<()> {
86    let mut runtime =
87        HarnessBridgeRuntime::new(Repository::open(repo.root())?, user_config.clone());
88    let json = if payload.trim().is_empty() {
89        Value::Null
90    } else {
91        serde_json::from_str::<Value>(payload).unwrap_or(Value::Null)
92    };
93    match harness {
94        "codex" => relay_codex(&mut runtime, event, &json),
95        "claude-code" => relay_claude(&mut runtime, event, &json),
96        "opencode" => relay_opencode(&mut runtime, event, &json),
97        other => Err(anyhow!("unsupported harness relay: {other}")),
98    }
99}
100
101struct HarnessBridgeRuntime {
102    repo: Repository,
103    user_config: UserConfig,
104    reports: SessionReportStore,
105}
106
107struct RegistryEntryRequest<'a> {
108    heddle_session_id: &'a str,
109    thread_name: Option<&'a str>,
110    thread_id: Option<&'a str>,
111    identity: &'a ResolvedIdentity,
112    probe: &'a HarnessProbeResult,
113    attach: &'a ResolvedAttachment,
114    client_instance_id: Option<&'a str>,
115    requested_entry: Option<&'a AgentEntry>,
116}
117
118struct CanonicalActorSessionRequest<'a> {
119    tentative_session: Session,
120    tentative_owns_session: bool,
121    entry: &'a AgentEntry,
122    probe: &'a HarnessProbeResult,
123    attach: &'a mut ResolvedAttachment,
124}
125
126struct AttachmentResolutionInput<'a> {
127    requested_entry: Option<&'a AgentEntry>,
128    explicit_heddle_session_id: Option<&'a str>,
129    client_instance_id: Option<&'a str>,
130    probe: &'a HarnessProbeResult,
131    token_claims: Option<&'a TokenClaims>,
132}
133
134fn relay_codex(runtime: &mut HarnessBridgeRuntime, _event: &str, payload: &Value) -> Result<()> {
135    let metadata = map_from_pairs([
136        (
137            "client_name",
138            value_string(payload, &["client"]).or_else(|| value_string(payload, &["client_name"])),
139        ),
140        ("model", value_string(payload, &["model"])),
141        (
142            "model_provider",
143            value_string(payload, &["model_provider"])
144                .or_else(|| value_string(payload, &["provider"])),
145        ),
146        (
147            "model_reasoning_effort",
148            value_string(payload, &["reasoning_effort"]),
149        ),
150    ]);
151    let opened = runtime.open_session(OpenSessionParams {
152        harness: Some("codex".to_string()),
153        summary: value_string(payload, &["message"]),
154        probe_metadata: metadata,
155        ..OpenSessionParams::default()
156    })?;
157    runtime.update_progress(UpdateProgressParams {
158        heddle_session_id: opened.heddle_session_id,
159        summary: value_string(payload, &["message"]),
160        harness: Some("codex".to_string()),
161        ..UpdateProgressParams::default()
162    })?;
163    Ok(())
164}
165
166fn relay_claude(runtime: &mut HarnessBridgeRuntime, event: &str, payload: &Value) -> Result<()> {
167    let metadata = map_from_pairs([
168        ("session_id", value_string(payload, &["session_id"])),
169        ("agent_id", value_string(payload, &["agent_id"])),
170        ("session_name", value_string(payload, &["session_name"])),
171        (
172            "transcript_path",
173            value_string(payload, &["transcript_path"]),
174        ),
175        (
176            "model",
177            value_string(payload, &["model", "id"]).or_else(|| value_string(payload, &["model"])),
178        ),
179        (
180            "model_display_name",
181            value_string(payload, &["model", "display_name"]),
182        ),
183        ("effort", value_string(payload, &["effort"])),
184        ("hook_event", Some(event.to_string())),
185        (
186            "status_line",
187            (event == "StatusLine").then(|| "1".to_string()),
188        ),
189        (
190            "touched_paths",
191            value_array_join(payload, &["tool_response", "filePaths"])
192                .or_else(|| value_string(payload, &["file_path"])),
193        ),
194        (
195            "input_tokens",
196            value_u64_string(payload, &["context_window", "total_input_tokens"]),
197        ),
198        (
199            "output_tokens",
200            value_u64_string(payload, &["context_window", "total_output_tokens"]),
201        ),
202        (
203            "cost_micros_usd",
204            value_cost_micros(payload, &["cost", "total_cost_usd"]),
205        ),
206    ]);
207    let opened = runtime.open_session(OpenSessionParams {
208        harness: Some("claude-code".to_string()),
209        model: value_string(payload, &["model", "display_name"])
210            .or_else(|| value_string(payload, &["model", "id"]))
211            .or_else(|| value_string(payload, &["model"])),
212        summary: value_string(payload, &["message"]).or_else(|| value_string(payload, &["reason"])),
213        probe_metadata: metadata.clone(),
214        ..OpenSessionParams::default()
215    })?;
216    match event {
217        "SessionEnd" => {
218            runtime.close_session(CloseSessionParams {
219                heddle_session_id: opened.heddle_session_id,
220                summary: value_string(payload, &["reason"])
221                    .or_else(|| value_string(payload, &["stop_hook_active"])),
222                outcome: Some("completed".to_string()),
223                ..CloseSessionParams::default()
224            })?;
225        }
226        "StatusLine" => {
227            runtime.update_progress(UpdateProgressParams {
228                heddle_session_id: opened.heddle_session_id.clone(),
229                harness: Some("claude-code".to_string()),
230                status: Some("StatusLine".to_string()),
231                message: value_string(payload, &["session_name"])
232                    .or_else(|| value_string(payload, &["cwd"]))
233                    .or_else(|| value_string(payload, &["workspace", "current_dir"])),
234                probe_metadata: metadata.clone(),
235                ..UpdateProgressParams::default()
236            })?;
237            runtime.record_usage(RecordUsageParams {
238                heddle_session_id: opened.heddle_session_id,
239                input_tokens: value_u64(payload, &["context_window", "total_input_tokens"]),
240                output_tokens: value_u64(payload, &["context_window", "total_output_tokens"]),
241                reasoning_tokens: value_u64(payload, &["context_window", "total_reasoning_tokens"]),
242                cache_creation_tokens: None,
243                cache_read_tokens: None,
244                tool_calls: None,
245                cost_micros_usd: value_cost_micros_u64(payload, &["cost", "total_cost_usd"]),
246            })?;
247        }
248        "Stop" => {
249            runtime.update_progress(UpdateProgressParams {
250                heddle_session_id: opened.heddle_session_id,
251                harness: Some("claude-code".to_string()),
252                status: Some("Stop".to_string()),
253                message: value_string(payload, &["message"])
254                    .or_else(|| value_string(payload, &["result"]))
255                    .or_else(|| value_string(payload, &["stop_reason"])),
256                probe_metadata: metadata,
257                ..UpdateProgressParams::default()
258            })?;
259            if let Err(err) = claude_hook::handle_stop_capture(
260                &runtime.repo,
261                &runtime.user_config,
262                payload,
263                "Claude Code turn",
264            ) {
265                tracing::warn!(?err, "heddle Stop hook capture failed");
266            }
267        }
268        "SubagentStop" => {
269            runtime.update_progress(UpdateProgressParams {
270                heddle_session_id: opened.heddle_session_id,
271                harness: Some("claude-code".to_string()),
272                status: Some("SubagentStop".to_string()),
273                touched_paths: csv_from_value(metadata.get("touched_paths")),
274                probe_metadata: metadata,
275                ..UpdateProgressParams::default()
276            })?;
277            if let Err(err) = claude_hook::handle_stop_capture(
278                &runtime.repo,
279                &runtime.user_config,
280                payload,
281                "Claude Code subagent turn",
282            ) {
283                tracing::warn!(?err, "heddle SubagentStop hook capture failed");
284            }
285            if let Err(err) = claude_hook::mark_subagent_complete(&runtime.repo, payload) {
286                tracing::debug!(?err, "heddle SubagentStop mark-complete failed");
287            }
288        }
289        "SubagentStart" => {
290            // open_session above has already created (or reattached) the
291            // child `AgentEntry` with `native_parent_actor_key` pointing at
292            // the parent session via the claude-code probe. The explicit
293            // branch exists so the relay's behaviour is traceable in tests
294            // and logs, and to preserve room for future subagent-specific
295            // bookkeeping.
296            runtime.update_progress(UpdateProgressParams {
297                heddle_session_id: opened.heddle_session_id,
298                harness: Some("claude-code".to_string()),
299                status: Some("SubagentStart".to_string()),
300                touched_paths: csv_from_value(metadata.get("touched_paths")),
301                probe_metadata: metadata,
302                ..UpdateProgressParams::default()
303            })?;
304        }
305        "UserPromptSubmit" => {
306            runtime.update_progress(UpdateProgressParams {
307                heddle_session_id: opened.heddle_session_id.clone(),
308                harness: Some("claude-code".to_string()),
309                status: Some("UserPromptSubmit".to_string()),
310                touched_paths: csv_from_value(metadata.get("touched_paths")),
311                probe_metadata: metadata,
312                ..UpdateProgressParams::default()
313            })?;
314            if let Err(err) = claude_hook::handle_user_prompt_segment_rotate(
315                &runtime.repo,
316                &opened.heddle_session_id,
317                payload,
318            ) {
319                tracing::debug!(?err, "heddle UserPromptSubmit segment rotation failed");
320            }
321        }
322        "PreToolUse" => {
323            runtime.update_progress(UpdateProgressParams {
324                heddle_session_id: opened.heddle_session_id,
325                harness: Some("claude-code".to_string()),
326                status: Some("PreToolUse".to_string()),
327                touched_paths: csv_from_value(metadata.get("touched_paths")),
328                probe_metadata: metadata,
329                ..UpdateProgressParams::default()
330            })?;
331            if let Err(err) = claude_hook::handle_pre_tool_use(&runtime.repo, payload) {
332                tracing::debug!(?err, "heddle PreToolUse context inject skipped");
333            }
334        }
335        _ => {
336            runtime.update_progress(UpdateProgressParams {
337                heddle_session_id: opened.heddle_session_id,
338                harness: Some("claude-code".to_string()),
339                status: Some(event.to_string()),
340                touched_paths: csv_from_value(metadata.get("touched_paths")),
341                probe_metadata: metadata,
342                ..UpdateProgressParams::default()
343            })?;
344        }
345    }
346    Ok(())
347}
348
349fn relay_opencode(runtime: &mut HarnessBridgeRuntime, event: &str, payload: &Value) -> Result<()> {
350    let metadata = map_from_pairs([
351        (
352            "session_id",
353            value_string(payload, &["sessionID"])
354                .or_else(|| value_string(payload, &["session_id"])),
355        ),
356        (
357            "parent_id",
358            value_string(payload, &["parentID"]).or_else(|| value_string(payload, &["parent_id"])),
359        ),
360        (
361            "client_name",
362            value_string(payload, &["client"]).or_else(|| std::env::var("OPENCODE_CLIENT").ok()),
363        ),
364        ("model", value_string(payload, &["model"])),
365        ("provider", value_string(payload, &["provider"])),
366        ("hook_event", Some(event.to_string())),
367        (
368            "touched_paths",
369            value_string(payload, &["file", "path"]).or_else(|| value_string(payload, &["path"])),
370        ),
371    ]);
372    let opened = runtime.open_session(OpenSessionParams {
373        harness: Some("opencode".to_string()),
374        model: value_string(payload, &["model"]),
375        provider: value_string(payload, &["provider"]),
376        probe_metadata: metadata.clone(),
377        ..OpenSessionParams::default()
378    })?;
379    runtime.update_progress(UpdateProgressParams {
380        heddle_session_id: opened.heddle_session_id,
381        harness: Some("opencode".to_string()),
382        status: Some(event.to_string()),
383        touched_paths: csv_from_value(metadata.get("touched_paths")),
384        probe_metadata: metadata,
385        ..UpdateProgressParams::default()
386    })?;
387    Ok(())
388}
389
390fn value_string(value: &Value, path: &[&str]) -> Option<String> {
391    let mut current = value;
392    for segment in path {
393        current = current.get(*segment)?;
394    }
395    match current {
396        Value::String(s) => Some(s.clone()),
397        Value::Bool(v) => Some(v.to_string()),
398        Value::Number(v) => Some(v.to_string()),
399        _ => None,
400    }
401}
402
403fn value_array_join(value: &Value, path: &[&str]) -> Option<String> {
404    let mut current = value;
405    for segment in path {
406        current = current.get(*segment)?;
407    }
408    current.as_array().map(|items| {
409        items
410            .iter()
411            .filter_map(|item| item.as_str().map(ToString::to_string))
412            .collect::<Vec<_>>()
413            .join(",")
414    })
415}
416
417fn value_u64_string(value: &Value, path: &[&str]) -> Option<String> {
418    let mut current = value;
419    for segment in path {
420        current = current.get(*segment)?;
421    }
422    current.as_u64().map(|v| v.to_string())
423}
424
425fn value_u64(value: &Value, path: &[&str]) -> Option<u64> {
426    let mut current = value;
427    for segment in path {
428        current = current.get(*segment)?;
429    }
430    current.as_u64()
431}
432
433fn value_cost_micros(value: &Value, path: &[&str]) -> Option<String> {
434    let mut current = value;
435    for segment in path {
436        current = current.get(*segment)?;
437    }
438    current
439        .as_f64()
440        .map(|v| ((v * 1_000_000.0).round() as u64).to_string())
441}
442
443fn value_cost_micros_u64(value: &Value, path: &[&str]) -> Option<u64> {
444    let mut current = value;
445    for segment in path {
446        current = current.get(*segment)?;
447    }
448    current.as_f64().map(|v| (v * 1_000_000.0).round() as u64)
449}
450
451fn map_from_pairs<const N: usize>(pairs: [(&str, Option<String>); N]) -> BTreeMap<String, String> {
452    pairs
453        .into_iter()
454        .filter_map(|(key, value)| value.map(|value| (key.to_string(), value)))
455        .collect()
456}
457
458fn csv_from_value(value: Option<&String>) -> Vec<String> {
459    value
460        .map(|value| {
461            value
462                .split(',')
463                .map(|item| item.trim().to_string())
464                .filter(|item| !item.is_empty())
465                .collect()
466        })
467        .unwrap_or_default()
468}
469
470impl HarnessBridgeRuntime {
471    fn new(repo: Repository, user_config: UserConfig) -> Self {
472        let reports = SessionReportStore::new(repo.root());
473        Self {
474            repo,
475            user_config,
476            reports,
477        }
478    }
479
480    fn handle_request(&mut self, request: BridgeRequest) -> BridgeResponse {
481        let response = match request.method.as_str() {
482            "open_session" => self
483                .decode_params::<OpenSessionParams>(request.params)
484                .and_then(|params| self.open_session(params))
485                .and_then(to_json_value),
486            "update_progress" => self
487                .decode_params::<UpdateProgressParams>(request.params)
488                .and_then(|params| self.update_progress(params))
489                .and_then(to_json_value),
490            "record_usage" => self
491                .decode_params::<RecordUsageParams>(request.params)
492                .and_then(|params| self.record_usage(params))
493                .and_then(to_json_value),
494            "record_touched_paths" => self
495                .decode_params::<RecordTouchedPathsParams>(request.params)
496                .and_then(|params| self.record_touched_paths(params))
497                .and_then(to_json_value),
498            "close_session" => self
499                .decode_params::<CloseSessionParams>(request.params)
500                .and_then(|params| self.close_session(params))
501                .and_then(to_json_value),
502            "flush_reports" => self
503                .decode_params::<FlushReportsParams>(request.params)
504                .and_then(|params| self.flush_reports(params))
505                .and_then(to_json_value),
506            other => Err(anyhow!("unknown method '{other}'")),
507        };
508
509        match response {
510            Ok(result) => BridgeResponse::ok(request.id, result),
511            Err(err) => BridgeResponse::error(request.id, "bridge_error", err.to_string()),
512        }
513    }
514
515    fn decode_params<T: for<'de> Deserialize<'de>>(&self, value: Value) -> Result<T> {
516        serde_json::from_value(value).map_err(|err| anyhow!(err))
517    }
518
519    fn open_session(&mut self, params: OpenSessionParams) -> Result<OpenSessionResult> {
520        if self.user_config.harness.mode == HarnessMode::Off {
521            return Err(anyhow!("harness integration is disabled in user config"));
522        }
523
524        let requested_transport = params
525            .transport
526            .unwrap_or(self.user_config.harness.transport);
527        let transcript_mode = params
528            .transcript_mode
529            .unwrap_or(self.user_config.harness.transcript);
530        let env_hints = merged_env_hints(&params.env_hints);
531        let token_claims = user_config_token_claims(&self.user_config);
532        let current_session = SessionManager::new(self.repo.root()).get_current_session()?;
533        let current_segment = current_session
534            .as_ref()
535            .and_then(|session| session.current_segment());
536        let probe = probe_harness_actor(&HarnessProbeInput {
537            argv: params.argv.clone(),
538            env_hints: env_hints.clone(),
539            explicit_harness: params.harness.clone(),
540            explicit_provider: params.provider.clone(),
541            explicit_model: params.model.clone(),
542            explicit_thinking_level: params.thinking_level.clone(),
543            explicit_policy: params.policy.clone(),
544            probe_metadata: params.probe_metadata.clone(),
545            current_provider: current_segment.map(|segment| segment.provider.clone()),
546            current_model: current_segment.map(|segment| segment.model.clone()),
547            current_policy: current_segment.and_then(|segment| segment.policy_id.clone()),
548            repo_root: self.repo.root().display().to_string(),
549        })?;
550        let identity = resolve_identity(
551            &self.repo,
552            &self.user_config,
553            IdentityHints {
554                harness: params.harness.clone(),
555                provider: params.provider.clone(),
556                model: params.model.clone(),
557                thinking_level: params.thinking_level.clone(),
558                policy: params.policy.clone(),
559                probe: probe.clone(),
560            },
561        )?;
562        let registry = AgentRegistry::new(self.repo.heddle_dir());
563        let requested_entry = resolve_requested_registry_entry(
564            &registry,
565            params.agent_session_id.as_deref(),
566            params.client_instance_id.as_deref(),
567        )?;
568
569        if self.user_config.harness.mode == HarnessMode::Required
570            && (identity.harness.is_none()
571                || identity.provider.is_none()
572                || identity.model.is_none())
573        {
574            return Err(anyhow!(
575                "harness mode is 'required' but harness/provider/model could not be resolved"
576            ));
577        }
578
579        let mut sessions = SessionManager::new(self.repo.root());
580        let principal = self.repo.get_principal()?;
581        let mut attach = resolve_actor_attachment(
582            &registry,
583            &self.repo,
584            &mut sessions,
585            AttachmentResolutionInput {
586                requested_entry: requested_entry.as_ref(),
587                explicit_heddle_session_id: params.heddle_session_id.as_deref(),
588                client_instance_id: params.client_instance_id.as_deref(),
589                probe: &probe,
590                token_claims: token_claims.as_ref(),
591            },
592        )?;
593        let (session, owns_session) = match &attach.target {
594            AttachTarget::ExistingSession(session) => {
595                let segment_id = session.current_segment_id.clone().unwrap_or_default();
596                sessions.set_current_session(&session.id, &segment_id)?;
597                (session.clone(), false)
598            }
599            AttachTarget::CreateNew {
600                _because_claimed: _,
601            } => {
602                let session = sessions.start_session(
603                    principal,
604                    identity
605                        .provider
606                        .clone()
607                        .unwrap_or_else(|| "unknown".to_string()),
608                    identity
609                        .model
610                        .clone()
611                        .unwrap_or_else(|| "unknown".to_string()),
612                    identity.policy.clone(),
613                )?;
614                (session, true)
615            }
616        };
617
618        let (thread_name, thread_id) =
619            self.resolve_harness_thread_binding(&params, &probe, &identity)?;
620        let entry = self.ensure_registry_entry(RegistryEntryRequest {
621            heddle_session_id: &session.id,
622            thread_name: thread_name.as_deref(),
623            thread_id: thread_id.as_deref(),
624            identity: &identity,
625            probe: &probe,
626            attach: &attach,
627            client_instance_id: params.client_instance_id.as_deref(),
628            requested_entry: requested_entry.as_ref(),
629        })?;
630        let (session, owns_session) = self.reuse_canonical_actor_session(
631            &mut sessions,
632            CanonicalActorSessionRequest {
633                tentative_session: session,
634                tentative_owns_session: owns_session,
635                entry: &entry,
636                probe: &probe,
637                attach: &mut attach,
638            },
639        )?;
640
641        let mut segment_id = session.current_segment_id.clone().unwrap_or_default();
642        if should_rotate_segment(&session, &identity) {
643            let segment = sessions.add_segment(
644                &session.id,
645                identity
646                    .provider
647                    .clone()
648                    .unwrap_or_else(|| "unknown".to_string()),
649                identity
650                    .model
651                    .clone()
652                    .unwrap_or_else(|| "unknown".to_string()),
653                identity.policy.clone(),
654            )?;
655            segment_id = segment.id;
656        }
657
658        let base_state = self
659            .repo
660            .current_state()?
661            .map(|state| state.change_id.to_string_full())
662            .or_else(|| {
663                self.repo
664                    .head()
665                    .ok()
666                    .flatten()
667                    .map(|id| id.to_string_full())
668            });
669        let worktree_changes_at_open = capture_worktree_change_snapshot(&self.repo)?;
670        let opened_at = Utc::now().to_rfc3339();
671        let mut report = SessionReportEnvelope {
672            version: 1,
673            heddle_session_id: session.id.clone(),
674            heddle_segment_id: (!segment_id.is_empty()).then_some(segment_id.clone()),
675            agent_session_id: Some(entry.session_id.clone()),
676            client_instance_id: entry.client_instance_id.clone(),
677            native_actor_key: entry.native_actor_key.clone(),
678            native_parent_actor_key: entry.native_parent_actor_key.clone(),
679            native_instance_key: entry.native_instance_key.clone(),
680            repo_root: self.repo.root().display().to_string(),
681            thread: thread_name.clone(),
682            thread_id,
683            task: params.task.clone(),
684            summary: params.summary.clone(),
685            opened_at,
686            closed_at: None,
687            base_state_at_open: base_state.clone(),
688            worktree_changes_at_open,
689            head_state_at_close: None,
690            transport_mode: transport_mode_name(requested_transport).to_string(),
691            transcript_mode: transcript_mode_name(transcript_mode).to_string(),
692            outcome: None,
693            harness: identity.to_transport_identity(),
694            progress: Vec::new(),
695            usage: UsageTotals::default(),
696            touched_paths: Vec::new(),
697            changed_paths: Vec::new(),
698            diff_summary: None,
699            transcript_refs: Vec::new(),
700            last_progress_at: None,
701            report_flush_state: Some("pending-local".to_string()),
702            attach_reason: Some(attach.attach_reason.clone()),
703            attach_precedence: attach.precedence.clone(),
704            winning_attach_rule: Some(attach.winning_rule.clone()),
705            probe_source: probe.probe_source.clone(),
706            probe_confidence: probe.confidence,
707            pending_flush: true,
708            last_flushed_at: None,
709            owns_session,
710        };
711        merge_unique_paths(&mut report.touched_paths, probe.touched_paths.clone());
712        merge_usage(&mut report.usage, &probe.usage_totals);
713        if transcript_mode != HarnessTranscriptMode::Off {
714            report.transcript_refs = probe.transcript_refs.clone();
715        }
716        self.reports.save(&report)?;
717        self.sync_registry_from_report(&report, AgentStatus::Active)?;
718        if matches!(requested_transport, HarnessTransport::Direct) {
719            enqueue_report(&self.reports, &mut report)?;
720            self.sync_registry_from_report(&report, AgentStatus::Active)?;
721        }
722
723        Ok(OpenSessionResult {
724            heddle_session_id: report.heddle_session_id.clone(),
725            heddle_segment_id: report.heddle_segment_id.clone(),
726            agent_session_id: report.agent_session_id.clone(),
727            created_session: owns_session,
728            harness: report.harness.harness.clone(),
729            provider: report.harness.provider.clone(),
730            model: report.harness.model.clone(),
731            thinking_level: report.harness.thinking_level.clone(),
732            report_flush_state: report.report_flush_state.clone(),
733            attach_reason: report.attach_reason.clone(),
734        })
735    }
736
737    fn update_progress(&mut self, params: UpdateProgressParams) -> Result<SessionMutationResult> {
738        let mut report = self
739            .reports
740            .load(&params.heddle_session_id)?
741            .ok_or_else(|| anyhow!("session report not found for {}", params.heddle_session_id))?;
742        let current_session = SessionManager::new(self.repo.root()).get_current_session()?;
743        let current_segment = current_session
744            .as_ref()
745            .and_then(|session| session.current_segment());
746        let probe = probe_harness_actor(&HarnessProbeInput {
747            argv: params.argv.clone(),
748            env_hints: merged_env_hints(&params.env_hints),
749            explicit_harness: params.harness.clone(),
750            explicit_provider: params.provider.clone(),
751            explicit_model: params.model.clone(),
752            explicit_thinking_level: params.thinking_level.clone(),
753            explicit_policy: params.policy.clone(),
754            probe_metadata: params.probe_metadata.clone(),
755            current_provider: current_segment.map(|segment| segment.provider.clone()),
756            current_model: current_segment.map(|segment| segment.model.clone()),
757            current_policy: current_segment.and_then(|segment| segment.policy_id.clone()),
758            repo_root: self.repo.root().display().to_string(),
759        })?;
760        let identity = resolve_identity(
761            &self.repo,
762            &self.user_config,
763            IdentityHints {
764                harness: params.harness.clone(),
765                provider: params.provider.clone(),
766                model: params.model.clone(),
767                thinking_level: params.thinking_level.clone(),
768                policy: params.policy.clone(),
769                probe: probe.clone(),
770            },
771        )?;
772        self.ensure_segment_for_report(&mut report, &identity)?;
773        if report.harness.harness.is_none() {
774            report.harness.harness = identity.harness.clone();
775        }
776        if report.harness.provider.is_none() {
777            report.harness.provider = identity.provider.clone();
778        }
779        if report.harness.model.is_none() {
780            report.harness.model = identity.model.clone();
781        }
782        if report.harness.thinking_level.is_none() {
783            report.harness.thinking_level = identity.thinking_level.clone();
784        }
785        if report.harness.policy.is_none() {
786            report.harness.policy = identity.policy.clone();
787        }
788        if report.native_actor_key.is_none() {
789            report.native_actor_key = probe.native_actor_key.clone();
790        }
791        if report.native_parent_actor_key.is_none() {
792            report.native_parent_actor_key = probe.native_parent_actor_key.clone();
793        }
794        if report.native_instance_key.is_none() {
795            report.native_instance_key = probe.native_instance_key.clone();
796        }
797        if report.probe_source.is_none() {
798            report.probe_source = probe.probe_source.clone();
799        }
800        if report.probe_confidence.is_none() {
801            report.probe_confidence = probe.confidence;
802        }
803
804        let recorded_at = Utc::now().to_rfc3339();
805        let checkpoint = ProgressCheckpoint {
806            status: params.status.clone(),
807            message: params.message.clone(),
808            completed_steps: params.completed_steps,
809            total_steps: params.total_steps,
810            touched_paths: normalize_paths(
811                params
812                    .touched_paths
813                    .into_iter()
814                    .chain(probe.touched_paths)
815                    .collect::<Vec<_>>(),
816            ),
817            recorded_at: recorded_at.clone(),
818        };
819        merge_unique_paths(
820            &mut report.touched_paths,
821            checkpoint.touched_paths.iter().cloned(),
822        );
823        merge_usage(&mut report.usage, &probe.usage_totals);
824        if report.transcript_mode != "off" && report.transcript_refs.is_empty() {
825            report.transcript_refs = probe.transcript_refs;
826        }
827        report.progress.push(checkpoint);
828        if let Some(summary) = params.summary {
829            report.summary = Some(summary);
830        }
831        report.last_progress_at = Some(recorded_at);
832        mark_pending_flush(&mut report);
833        self.persist_report(report)
834    }
835
836    fn resolve_harness_thread_binding(
837        &self,
838        params: &OpenSessionParams,
839        probe: &HarnessProbeResult,
840        identity: &ResolvedIdentity,
841    ) -> Result<(Option<String>, Option<String>)> {
842        if let Some(thread) = params.thread.clone() {
843            let thread_id = thread_id_for_name(&self.repo, Some(&thread))?;
844            return Ok((Some(thread), thread_id));
845        }
846
847        let current_attached = match self.repo.head_ref()? {
848            Head::Attached { thread } => Some(thread),
849            Head::Detached { .. } => None,
850        };
851
852        if !probe.attach_hints.root_actor
853            && self.user_config.harness.threading.subagent
854                == UserHarnessSubagentThreadPolicy::CreateChild
855            && let Some(parent_thread) =
856                resolve_parent_thread_for_subagent(&self.repo, probe, current_attached.as_deref())?
857            && can_create_harness_thread(&self.repo, Some(&parent_thread), Some(&parent_thread))?
858        {
859            let name = allocate_thread_name(
860                &self.repo,
861                &format!(
862                    "{}/{}",
863                    parent_thread,
864                    sanitize_name(&preferred_thread_slug(params, probe, identity))
865                ),
866            )?;
867            self.ensure_harness_thread(
868                &name,
869                Some(&parent_thread),
870                Some(&parent_thread),
871                params.task.clone(),
872            )?;
873            let thread_id = thread_id_for_name(&self.repo, Some(&name))?;
874            return Ok((Some(name), thread_id));
875        }
876
877        if probe.attach_hints.root_actor
878            && self.user_config.harness.threading.root_actor
879                == UserHarnessRootThreadPolicy::CreateNew
880            && let Some(current) = current_attached.clone()
881            && can_create_harness_thread(&self.repo, Some(&current), None)?
882        {
883            let name = allocate_thread_name(
884                &self.repo,
885                &format!(
886                    "{}/{}",
887                    current,
888                    sanitize_name(&preferred_thread_slug(params, probe, identity))
889                ),
890            )?;
891            self.ensure_harness_thread(&name, Some(&current), None, params.task.clone())?;
892            let thread_id = thread_id_for_name(&self.repo, Some(&name))?;
893            return Ok((Some(name), thread_id));
894        }
895
896        let thread_id = thread_id_for_name(&self.repo, current_attached.as_deref())?;
897        Ok((current_attached, thread_id))
898    }
899
900    fn ensure_harness_thread(
901        &self,
902        name: &str,
903        target_thread: Option<&str>,
904        parent_thread: Option<&str>,
905        task: Option<String>,
906    ) -> Result<()> {
907        let manager = ThreadManager::new(self.repo.heddle_dir());
908        if manager.load(name)?.is_some() {
909            return Ok(());
910        }
911
912        let base_state = self
913            .resolve_harness_thread_base_state(target_thread, parent_thread)?
914            .ok_or_else(|| anyhow!("No current state to start a thread from"))?;
915        if self.repo.refs().get_thread(name)?.is_none() {
916            self.repo
917                .refs()
918                .set_thread_cas(name, refs::RefExpectation::Missing, &base_state)?;
919            self.repo.oplog().record_thread_create(
920                name,
921                &base_state,
922                Some(&self.repo.op_scope()),
923            )?;
924        }
925
926        let workspace_mode = self
927            .user_config
928            .harness
929            .threading
930            .workspace_default
931            .unwrap_or(UserThreadWorkspaceMode::Heavy);
932        let thread_mode = match workspace_mode {
933            UserThreadWorkspaceMode::Heavy | UserThreadWorkspaceMode::Auto => {
934                ThreadMode::Lightweight
935            }
936            UserThreadWorkspaceMode::Light => ThreadMode::Virtualized,
937        };
938        let path = match thread_mode {
939            ThreadMode::Materialized | ThreadMode::Lightweight => {
940                default_private_thread_path(&self.repo, name)
941            }
942            // Harness-managed light workspaces still need mount lifecycle
943            // wiring before they can become the default execution root.
944            ThreadMode::Virtualized => default_private_thread_path(&self.repo, name),
945        };
946        let abs_path = prepare_worktree_target(&self.repo, &path)?;
947        write_isolated_checkout(&self.repo, &abs_path, &base_state, Some(name))?;
948
949        let base_state_obj = self
950            .repo
951            .store()
952            .get_state(&base_state)?
953            .ok_or_else(|| anyhow!("Base state '{}' not found", base_state.short()))?;
954        let thread = Thread {
955            id: name.to_string(),
956            thread: name.to_string(),
957            target_thread: target_thread.map(ToString::to_string),
958            parent_thread: parent_thread.map(ToString::to_string),
959            mode: thread_mode.clone(),
960            state: ThreadState::Active,
961            base_state: base_state.short(),
962            base_root: base_state_obj.tree.short(),
963            current_state: Some(base_state.short()),
964            merged_state: None,
965            task,
966            execution_path: abs_path.clone(),
967            materialized_path: match thread_mode {
968                ThreadMode::Materialized => Some(abs_path),
969                // See note above: harness can't currently produce
970                // Virtualized, so defaulting to None matches the
971                // Lightweight branch.
972                ThreadMode::Lightweight | ThreadMode::Virtualized => None,
973            },
974            changed_paths: vec![],
975            impact_categories: vec![],
976            heavy_impact_paths: vec![],
977            promotion_suggested: false,
978            freshness: if target_thread.is_some() {
979                ThreadFreshness::Current
980            } else {
981                ThreadFreshness::Unknown
982            },
983            verification_summary: summarize_verification(base_state_obj.verification.as_ref()),
984            confidence_summary: summarize_confidence(base_state_obj.confidence),
985            integration_policy_result: ThreadIntegrationPolicy::default(),
986            created_at: Utc::now(),
987            updated_at: Utc::now(),
988            ephemeral: None,
989            // Mark this as harness-created so `heddle thread list`
990            // hides it by default and `heddle thread cleanup --auto`
991            // can sweep it once stale. (Item 2.2 of the heddle 6→8
992            // plan.)
993            auto: true,
994            // The harness's create-on-rotate path doesn't materialize
995            // a heavy checkout, so there's nothing to redirect.
996            shared_target_dir: None,
997        };
998        manager.save(&thread)?;
999        Ok(())
1000    }
1001
1002    fn resolve_harness_thread_base_state(
1003        &self,
1004        target_thread: Option<&str>,
1005        parent_thread: Option<&str>,
1006    ) -> Result<Option<objects::object::ChangeId>> {
1007        resolve_harness_thread_base_state(&self.repo, target_thread, parent_thread)
1008    }
1009
1010    fn record_usage(&mut self, params: RecordUsageParams) -> Result<SessionMutationResult> {
1011        let mut report = self
1012            .reports
1013            .load(&params.heddle_session_id)?
1014            .ok_or_else(|| anyhow!("session report not found for {}", params.heddle_session_id))?;
1015        if let Some(input) = params.input_tokens {
1016            report.usage.input_tokens = Some(max_u64(report.usage.input_tokens, input));
1017        }
1018        if let Some(output) = params.output_tokens {
1019            report.usage.output_tokens = Some(max_u64(report.usage.output_tokens, output));
1020        }
1021        if let Some(reasoning) = params.reasoning_tokens {
1022            report.usage.reasoning_tokens = Some(max_u64(report.usage.reasoning_tokens, reasoning));
1023        }
1024        if let Some(cache_creation) = params.cache_creation_tokens {
1025            report.usage.cache_creation_tokens =
1026                Some(max_u64(report.usage.cache_creation_tokens, cache_creation));
1027        }
1028        if let Some(cache_read) = params.cache_read_tokens {
1029            report.usage.cache_read_tokens =
1030                Some(max_u64(report.usage.cache_read_tokens, cache_read));
1031        }
1032        if let Some(tool_calls) = params.tool_calls {
1033            report.usage.tool_calls = Some(max_u32(report.usage.tool_calls, tool_calls));
1034        }
1035        if let Some(cost) = params.cost_micros_usd {
1036            report.usage.cost_micros_usd = Some(max_u64(report.usage.cost_micros_usd, cost));
1037        }
1038        mark_pending_flush(&mut report);
1039        self.persist_report(report)
1040    }
1041
1042    fn record_touched_paths(
1043        &mut self,
1044        params: RecordTouchedPathsParams,
1045    ) -> Result<SessionMutationResult> {
1046        let mut report = self
1047            .reports
1048            .load(&params.heddle_session_id)?
1049            .ok_or_else(|| anyhow!("session report not found for {}", params.heddle_session_id))?;
1050        merge_unique_paths(&mut report.touched_paths, normalize_paths(params.paths));
1051        mark_pending_flush(&mut report);
1052        self.persist_report(report)
1053    }
1054
1055    fn close_session(&mut self, params: CloseSessionParams) -> Result<CloseSessionResult> {
1056        let mut report = self
1057            .reports
1058            .load(&params.heddle_session_id)?
1059            .ok_or_else(|| anyhow!("session report not found for {}", params.heddle_session_id))?;
1060        report.closed_at = Some(Utc::now().to_rfc3339());
1061        report.outcome = params.outcome.clone();
1062        if let Some(summary) = params.summary {
1063            report.summary = Some(summary);
1064        }
1065        if let Some(transcript_refs) = params.transcript_refs {
1066            report.transcript_refs = transcript_refs;
1067        }
1068        let final_diff = compute_final_diff(
1069            &self.repo,
1070            report.base_state_at_open.as_deref(),
1071            &report.worktree_changes_at_open,
1072        )?;
1073        report.head_state_at_close = final_diff.head_state;
1074        report.changed_paths = final_diff.changed_paths;
1075        report.diff_summary = Some(final_diff.diff_summary);
1076        mark_pending_flush(&mut report);
1077        if report.owns_session {
1078            let mut sessions = SessionManager::new(self.repo.root());
1079            if let Ok(Some(session)) = sessions.get_session(&report.heddle_session_id)
1080                && session.is_active()
1081            {
1082                let _ = sessions.end_session(Some(&report.heddle_session_id));
1083            }
1084        }
1085
1086        let transport = params
1087            .transport
1088            .unwrap_or(self.user_config.harness.transport);
1089        if matches!(transport, HarnessTransport::Direct | HarnessTransport::End) {
1090            enqueue_report(&self.reports, &mut report)?;
1091        } else {
1092            self.reports.save(&report)?;
1093        }
1094        self.sync_registry_from_report(&report, AgentStatus::Complete)?;
1095        Ok(CloseSessionResult {
1096            heddle_session_id: report.heddle_session_id,
1097            changed_paths: report.changed_paths,
1098            diff_summary: report.diff_summary.unwrap_or_default(),
1099            report_flush_state: report.report_flush_state,
1100        })
1101    }
1102
1103    fn flush_reports(&mut self, params: FlushReportsParams) -> Result<FlushReportsResult> {
1104        let mut flushed = 0usize;
1105        let session_ids = match params.heddle_session_id {
1106            Some(session_id) => vec![session_id],
1107            None => self.reports.list_pending()?,
1108        };
1109        for session_id in session_ids {
1110            let Some(mut report) = self.reports.load(&session_id)? else {
1111                continue;
1112            };
1113            if !report.pending_flush {
1114                continue;
1115            }
1116            enqueue_report(&self.reports, &mut report)?;
1117            let status = if report.closed_at.is_some() {
1118                AgentStatus::Complete
1119            } else {
1120                AgentStatus::Active
1121            };
1122            self.sync_registry_from_report(&report, status)?;
1123            flushed += 1;
1124        }
1125        Ok(FlushReportsResult { flushed })
1126    }
1127
1128    fn persist_report(
1129        &mut self,
1130        mut report: SessionReportEnvelope,
1131    ) -> Result<SessionMutationResult> {
1132        let transport = transport_from_report(&report, self.user_config.harness.transport);
1133        match transport {
1134            HarnessTransport::Direct => {
1135                enqueue_report(&self.reports, &mut report)?;
1136            }
1137            HarnessTransport::Spool | HarnessTransport::End => {
1138                self.reports.save(&report)?;
1139            }
1140        }
1141        self.sync_registry_from_report(&report, AgentStatus::Active)?;
1142        Ok(SessionMutationResult {
1143            heddle_session_id: report.heddle_session_id,
1144            heddle_segment_id: report.heddle_segment_id,
1145            report_flush_state: report.report_flush_state,
1146        })
1147    }
1148
1149    fn ensure_segment_for_report(
1150        &self,
1151        report: &mut SessionReportEnvelope,
1152        identity: &ResolvedIdentity,
1153    ) -> Result<()> {
1154        let mut sessions = SessionManager::new(self.repo.root());
1155        let Some(session) = sessions.get_session(&report.heddle_session_id)? else {
1156            return Ok(());
1157        };
1158        if !session.is_active() || !should_rotate_segment(&session, identity) {
1159            return Ok(());
1160        }
1161        let segment = sessions.add_segment(
1162            &report.heddle_session_id,
1163            identity
1164                .provider
1165                .clone()
1166                .unwrap_or_else(|| "unknown".to_string()),
1167            identity
1168                .model
1169                .clone()
1170                .unwrap_or_else(|| "unknown".to_string()),
1171            identity.policy.clone(),
1172        )?;
1173        report.heddle_segment_id = Some(segment.id);
1174        if identity.provider.is_some() {
1175            report.harness.provider = identity.provider.clone();
1176        }
1177        if identity.model.is_some() {
1178            report.harness.model = identity.model.clone();
1179        }
1180        if identity.policy.is_some() {
1181            report.harness.policy = identity.policy.clone();
1182        }
1183        if identity.thinking_level.is_some() {
1184            report.harness.thinking_level = identity.thinking_level.clone();
1185        }
1186        Ok(())
1187    }
1188
1189    fn ensure_registry_entry(&self, request: RegistryEntryRequest<'_>) -> Result<AgentEntry> {
1190        let RegistryEntryRequest {
1191            heddle_session_id,
1192            thread_name,
1193            thread_id,
1194            identity,
1195            probe,
1196            attach,
1197            client_instance_id,
1198            requested_entry,
1199        } = request;
1200        let registry = AgentRegistry::new(self.repo.heddle_dir());
1201        let fallback_entry = if client_instance_id.is_some()
1202            || probe.native_actor_key.is_some()
1203            || probe.native_instance_key.is_some()
1204        {
1205            None
1206        } else {
1207            find_matching_registry_entry(&registry, &self.repo, heddle_session_id, thread_name)?
1208        };
1209        if let Some(entry) = requested_entry
1210            .cloned()
1211            .or_else(|| attach.matched_entry.clone())
1212            .or(fallback_entry)
1213        {
1214            return registry
1215                .update_entry(&entry.session_id, |existing| {
1216                    if client_instance_id.is_some() {
1217                        existing.client_instance_id = client_instance_id.map(ToString::to_string);
1218                    }
1219                    if probe.native_actor_key.is_some() {
1220                        existing.native_actor_key = probe.native_actor_key.clone();
1221                    }
1222                    if probe.native_parent_actor_key.is_some() {
1223                        existing.native_parent_actor_key = probe.native_parent_actor_key.clone();
1224                    }
1225                    if probe.native_instance_key.is_some() {
1226                        existing.native_instance_key = probe.native_instance_key.clone();
1227                    }
1228                    existing.heddle_session_id = Some(heddle_session_id.to_string());
1229                    existing.thread_id = thread_id.map(ToString::to_string);
1230                    if let Some(thread_name) = thread_name {
1231                        existing.thread = thread_name.to_string();
1232                    }
1233                    existing.path = Some(self.repo.root().to_path_buf());
1234                    if identity.provider.is_some() {
1235                        existing.provider = identity.provider.clone();
1236                    }
1237                    if identity.model.is_some() {
1238                        existing.model = identity.model.clone();
1239                    }
1240                    if identity.harness.is_some() {
1241                        existing.harness = identity.harness.clone();
1242                    }
1243                    if identity.thinking_level.is_some() {
1244                        existing.thinking_level = identity.thinking_level.clone();
1245                    }
1246                    existing.attach_reason = Some(attach.attach_reason.clone());
1247                    existing.attach_precedence = attach.precedence.clone();
1248                    existing.winning_attach_rule = Some(attach.winning_rule.clone());
1249                    existing.probe_source = probe.probe_source.clone();
1250                    existing.probe_confidence = probe.confidence;
1251                    existing.status = AgentStatus::Active;
1252                })?
1253                .ok_or_else(|| anyhow!("registry entry disappeared during update"));
1254        }
1255
1256        if probe.native_actor_key.is_some() {
1257            let (entry, _) = registry.find_or_create_active_entry(
1258                |entry| {
1259                    claude_actor_compatible(entry, probe, self.repo.root())
1260                        && entry.native_actor_key == probe.native_actor_key
1261                },
1262                |existing| {
1263                    if client_instance_id.is_some() {
1264                        existing.client_instance_id = client_instance_id.map(ToString::to_string);
1265                    }
1266                    if existing.heddle_session_id.is_none() {
1267                        existing.heddle_session_id = Some(heddle_session_id.to_string());
1268                    }
1269                    existing.thread_id = thread_id.map(ToString::to_string);
1270                    if let Some(thread_name) = thread_name {
1271                        existing.thread = thread_name.to_string();
1272                    }
1273                    existing.path = Some(self.repo.root().to_path_buf());
1274                    if identity.provider.is_some() {
1275                        existing.provider = identity.provider.clone();
1276                    }
1277                    if identity.model.is_some() {
1278                        existing.model = identity.model.clone();
1279                    }
1280                    if identity.harness.is_some() {
1281                        existing.harness = identity.harness.clone();
1282                    }
1283                    if identity.thinking_level.is_some() {
1284                        existing.thinking_level = identity.thinking_level.clone();
1285                    }
1286                    if probe.native_parent_actor_key.is_some() {
1287                        existing.native_parent_actor_key = probe.native_parent_actor_key.clone();
1288                    }
1289                    if probe.native_instance_key.is_some() {
1290                        existing.native_instance_key = probe.native_instance_key.clone();
1291                    }
1292                    existing.attach_reason = Some(attach.attach_reason.clone());
1293                    existing.attach_precedence = attach.precedence.clone();
1294                    existing.winning_attach_rule = Some(attach.winning_rule.clone());
1295                    existing.probe_source = probe.probe_source.clone();
1296                    existing.probe_confidence = probe.confidence;
1297                    existing.status = AgentStatus::Active;
1298                },
1299                |session_id| {
1300                    Ok(AgentEntry {
1301                        session_id: session_id.to_string(),
1302                        client_instance_id: client_instance_id.map(ToString::to_string),
1303                        native_actor_key: probe.native_actor_key.clone(),
1304                        native_parent_actor_key: probe.native_parent_actor_key.clone(),
1305                        native_instance_key: probe.native_instance_key.clone(),
1306                        heddle_session_id: Some(heddle_session_id.to_string()),
1307                        thread_id: thread_id.map(ToString::to_string),
1308                        thread: thread_name.unwrap_or("detached").to_string(),
1309                        pid: Some(std::process::id()),
1310                        boot_id: None,
1311                        liveness_path: None,
1312                        heartbeat_at: Some(Utc::now()),
1313                        anchor_state: self.repo.head()?.map(|id| id.to_string_full()),
1314                        anchor_root: None,
1315                        reservation_token: Some(objects::store::generate_agent_id()),
1316                        path: Some(self.repo.root().to_path_buf()),
1317                        base_state: self.repo.head()?.map(|id| id.short()).unwrap_or_default(),
1318                        started_at: Utc::now(),
1319                        provider: identity.provider.clone(),
1320                        model: identity.model.clone(),
1321                        harness: identity.harness.clone(),
1322                        thinking_level: identity.thinking_level.clone(),
1323                        usage_summary: AgentUsageSummary::default(),
1324                        last_progress_at: None,
1325                        report_flush_state: Some("pending-local".to_string()),
1326                        attach_reason: Some(attach.attach_reason.clone()),
1327                        attach_precedence: attach.precedence.clone(),
1328                        winning_attach_rule: Some(attach.winning_rule.clone()),
1329                        probe_source: probe.probe_source.clone(),
1330                        probe_confidence: probe.confidence,
1331                        status: AgentStatus::Active,
1332                        completed_at: None,
1333                        context_queries: vec![],
1334                    })
1335                },
1336            )?;
1337            return Ok(entry);
1338        }
1339
1340        Ok(registry.create_generated_entry(|session_id| {
1341            Ok(AgentEntry {
1342                session_id: session_id.to_string(),
1343                client_instance_id: client_instance_id.map(ToString::to_string),
1344                native_actor_key: probe.native_actor_key.clone(),
1345                native_parent_actor_key: probe.native_parent_actor_key.clone(),
1346                native_instance_key: probe.native_instance_key.clone(),
1347                heddle_session_id: Some(heddle_session_id.to_string()),
1348                thread_id: thread_id.map(ToString::to_string),
1349                thread: thread_name.unwrap_or("detached").to_string(),
1350                pid: Some(std::process::id()),
1351                boot_id: None,
1352                liveness_path: None,
1353                heartbeat_at: Some(Utc::now()),
1354                anchor_state: self.repo.head()?.map(|id| id.to_string_full()),
1355                anchor_root: None,
1356                reservation_token: Some(objects::store::generate_agent_id()),
1357                path: Some(self.repo.root().to_path_buf()),
1358                base_state: self.repo.head()?.map(|id| id.short()).unwrap_or_default(),
1359                started_at: Utc::now(),
1360                provider: identity.provider.clone(),
1361                model: identity.model.clone(),
1362                harness: identity.harness.clone(),
1363                thinking_level: identity.thinking_level.clone(),
1364                usage_summary: AgentUsageSummary::default(),
1365                last_progress_at: None,
1366                report_flush_state: Some("pending-local".to_string()),
1367                attach_reason: Some(attach.attach_reason.clone()),
1368                attach_precedence: attach.precedence.clone(),
1369                winning_attach_rule: Some(attach.winning_rule.clone()),
1370                probe_source: probe.probe_source.clone(),
1371                probe_confidence: probe.confidence,
1372                status: AgentStatus::Active,
1373                completed_at: None,
1374                context_queries: vec![],
1375            })
1376        })?)
1377    }
1378
1379    fn reuse_canonical_actor_session(
1380        &self,
1381        sessions: &mut SessionManager,
1382        request: CanonicalActorSessionRequest<'_>,
1383    ) -> Result<(Session, bool)> {
1384        let CanonicalActorSessionRequest {
1385            tentative_session,
1386            tentative_owns_session,
1387            entry,
1388            probe,
1389            attach,
1390        } = request;
1391        let Some(canonical_session_id) = entry.heddle_session_id.as_deref() else {
1392            return Ok((tentative_session, tentative_owns_session));
1393        };
1394        if canonical_session_id == tentative_session.id {
1395            return Ok((tentative_session, tentative_owns_session));
1396        }
1397
1398        if tentative_owns_session
1399            && let Ok(Some(session)) = sessions.get_session(&tentative_session.id)
1400            && session.is_active()
1401        {
1402            let _ = sessions.end_session(Some(&tentative_session.id));
1403        }
1404
1405        let canonical_session = sessions
1406            .get_session(canonical_session_id)?
1407            .ok_or_else(|| anyhow!("session not found: {canonical_session_id}"))?;
1408        let canonical_segment_id = canonical_session
1409            .current_segment_id
1410            .clone()
1411            .unwrap_or_default();
1412        sessions.set_current_session(canonical_session_id, &canonical_segment_id)?;
1413
1414        if let Some(native_actor_key) = probe
1415            .native_actor_key
1416            .as_deref()
1417            .or(entry.native_actor_key.as_deref())
1418        {
1419            attach.precedence.push(format!(
1420                "post-create-native-actor-key:{native_actor_key}:matched"
1421            ));
1422            attach.attach_reason = format!(
1423                "reused existing native actor {} on Heddle session {}",
1424                native_actor_key, canonical_session_id
1425            );
1426            attach.winning_rule = "native-actor-key-post-create".to_string();
1427        }
1428
1429        Ok((canonical_session, false))
1430    }
1431
1432    fn sync_registry_from_report(
1433        &self,
1434        report: &SessionReportEnvelope,
1435        status: AgentStatus,
1436    ) -> Result<()> {
1437        let registry = AgentRegistry::new(self.repo.heddle_dir());
1438        let entry = if let Some(agent_session_id) = &report.agent_session_id {
1439            registry.update_entry(agent_session_id, |entry| {
1440                if report.client_instance_id.is_some() {
1441                    entry.client_instance_id = report.client_instance_id.clone();
1442                }
1443                if report.native_actor_key.is_some() {
1444                    entry.native_actor_key = report.native_actor_key.clone();
1445                }
1446                if report.native_parent_actor_key.is_some() {
1447                    entry.native_parent_actor_key = report.native_parent_actor_key.clone();
1448                }
1449                if report.native_instance_key.is_some() {
1450                    entry.native_instance_key = report.native_instance_key.clone();
1451                }
1452                entry.heddle_session_id = Some(report.heddle_session_id.clone());
1453                entry.path = Some(self.repo.root().to_path_buf());
1454                entry.harness = report.harness.harness.clone();
1455                entry.provider = report.harness.provider.clone();
1456                entry.model = report.harness.model.clone();
1457                entry.thinking_level = report.harness.thinking_level.clone();
1458                entry.usage_summary = usage_to_summary(&report.usage);
1459                entry.last_progress_at =
1460                    report.last_progress_at.as_deref().and_then(parse_timestamp);
1461                entry.report_flush_state = report.report_flush_state.clone();
1462                entry.attach_reason = report.attach_reason.clone();
1463                entry.attach_precedence = report.attach_precedence.clone();
1464                entry.winning_attach_rule = report.winning_attach_rule.clone();
1465                entry.probe_source = report.probe_source.clone();
1466                entry.probe_confidence = report.probe_confidence;
1467                entry.status = status.clone();
1468                entry.completed_at = match status {
1469                    AgentStatus::Active => None,
1470                    AgentStatus::Abandoned | AgentStatus::Complete | AgentStatus::Merged => {
1471                        Some(Utc::now())
1472                    }
1473                };
1474            })?
1475        } else {
1476            None
1477        };
1478
1479        if entry.is_none() {
1480            let resolved = self.ensure_registry_entry(RegistryEntryRequest {
1481                heddle_session_id: &report.heddle_session_id,
1482                thread_name: report.thread.as_deref(),
1483                thread_id: report.thread_id.as_deref(),
1484                identity: &ResolvedIdentity {
1485                    harness: report.harness.harness.clone(),
1486                    provider: report.harness.provider.clone(),
1487                    model: report.harness.model.clone(),
1488                    thinking_level: report.harness.thinking_level.clone(),
1489                    policy: report.harness.policy.clone(),
1490                },
1491                probe: &HarnessProbeResult {
1492                    native_actor_key: report.native_actor_key.clone(),
1493                    native_parent_actor_key: report.native_parent_actor_key.clone(),
1494                    native_instance_key: report.native_instance_key.clone(),
1495                    probe_source: report.probe_source.clone(),
1496                    confidence: report.probe_confidence,
1497                    ..HarnessProbeResult::default()
1498                },
1499                attach: &ResolvedAttachment {
1500                    target: AttachTarget::CreateNew {
1501                        _because_claimed: false,
1502                    },
1503                    matched_entry: None,
1504                    attach_reason: report.attach_reason.clone().unwrap_or_else(|| {
1505                        format!(
1506                            "created actor for Heddle session {}",
1507                            report.heddle_session_id
1508                        )
1509                    }),
1510                    precedence: report.attach_precedence.clone(),
1511                    winning_rule: report
1512                        .winning_attach_rule
1513                        .clone()
1514                        .unwrap_or_else(|| "report-sync".to_string()),
1515                },
1516                client_instance_id: report.client_instance_id.as_deref(),
1517                requested_entry: None,
1518            })?;
1519            let mut report = report.clone();
1520            report.agent_session_id = Some(resolved.session_id);
1521            self.reports.save(&report)?;
1522        }
1523        Ok(())
1524    }
1525}
1526
1527#[derive(Debug, Clone, Default)]
1528struct ResolvedIdentity {
1529    harness: Option<String>,
1530    provider: Option<String>,
1531    model: Option<String>,
1532    thinking_level: Option<String>,
1533    policy: Option<String>,
1534}
1535
1536impl ResolvedIdentity {
1537    fn to_transport_identity(&self) -> HarnessIdentity {
1538        HarnessIdentity {
1539            harness: self.harness.clone(),
1540            provider: self.provider.clone(),
1541            model: self.model.clone(),
1542            thinking_level: self.thinking_level.clone(),
1543            policy: self.policy.clone(),
1544        }
1545    }
1546}
1547
1548struct IdentityHints {
1549    harness: Option<String>,
1550    provider: Option<String>,
1551    model: Option<String>,
1552    thinking_level: Option<String>,
1553    policy: Option<String>,
1554    probe: HarnessProbeResult,
1555}
1556
1557fn resolve_identity(
1558    repo: &Repository,
1559    user_config: &UserConfig,
1560    hints: IdentityHints,
1561) -> Result<ResolvedIdentity> {
1562    let current_session = SessionManager::new(repo.root()).get_current_session()?;
1563    let current_segment = current_session
1564        .as_ref()
1565        .and_then(|session| session.current_segment());
1566    let token_claims = if user_config.harness.auto_infer {
1567        user_config_token_claims(user_config)
1568    } else {
1569        None
1570    };
1571    let harness_override = resolved_harness_override(
1572        user_config,
1573        hints.harness.as_deref(),
1574        hints.probe.harness.as_deref(),
1575    );
1576
1577    Ok(ResolvedIdentity {
1578        harness: hints.harness.or(hints.probe.harness),
1579        provider: hints
1580            .provider
1581            .or(hints.probe.provider)
1582            .or_else(|| current_segment.map(|segment| segment.provider.clone()))
1583            .or_else(|| {
1584                token_claims
1585                    .as_ref()
1586                    .and_then(|claims| claims.agent_provider.clone())
1587            })
1588            .or_else(|| harness_override.and_then(|entry| entry.provider.clone()))
1589            .or_else(|| user_config.agent.provider.clone()),
1590        model: hints
1591            .model
1592            .or(hints.probe.model)
1593            .or_else(|| current_segment.map(|segment| segment.model.clone()))
1594            .or_else(|| {
1595                token_claims
1596                    .as_ref()
1597                    .and_then(|claims| claims.agent_model.clone())
1598            })
1599            .or_else(|| harness_override.and_then(|entry| entry.model.clone()))
1600            .or_else(|| user_config.agent.model.clone()),
1601        thinking_level: hints
1602            .thinking_level
1603            .or(hints.probe.thinking_level)
1604            .or_else(|| harness_override.and_then(|entry| entry.thinking_level.clone())),
1605        policy: hints
1606            .policy
1607            .or(hints.probe.policy)
1608            .or_else(|| current_segment.and_then(|segment| segment.policy_id.clone()))
1609            .or_else(|| harness_override.and_then(|entry| entry.policy.clone()))
1610            .or_else(|| user_config.agent.default_policy.clone()),
1611    })
1612}
1613
1614fn resolved_harness_override<'a>(
1615    user_config: &'a UserConfig,
1616    explicit: Option<&str>,
1617    fingerprint: Option<&str>,
1618) -> Option<&'a UserHarnessOverride> {
1619    explicit
1620        .and_then(|name| user_config.harness.harnesses.get(name))
1621        .or_else(|| fingerprint.and_then(|name| user_config.harness.harnesses.get(name)))
1622}
1623
1624enum AttachTarget {
1625    ExistingSession(objects::object::Session),
1626    CreateNew { _because_claimed: bool },
1627}
1628
1629struct ResolvedAttachment {
1630    target: AttachTarget,
1631    matched_entry: Option<AgentEntry>,
1632    attach_reason: String,
1633    precedence: Vec<String>,
1634    winning_rule: String,
1635}
1636
1637fn resolve_actor_attachment(
1638    registry: &AgentRegistry,
1639    repo: &Repository,
1640    sessions: &mut SessionManager,
1641    input: AttachmentResolutionInput<'_>,
1642) -> Result<ResolvedAttachment> {
1643    let AttachmentResolutionInput {
1644        requested_entry,
1645        explicit_heddle_session_id,
1646        client_instance_id,
1647        probe,
1648        token_claims,
1649    } = input;
1650    let mut precedence = Vec::new();
1651    if let Some(entry) = requested_entry
1652        && let Some(bound_session_id) = entry.heddle_session_id.as_deref()
1653    {
1654        precedence.push(format!(
1655            "explicit-agent-session:{}:matched",
1656            entry.session_id
1657        ));
1658        let session = sessions
1659            .get_session(bound_session_id)?
1660            .ok_or_else(|| anyhow!("session not found: {bound_session_id}"))?;
1661        if !session.is_active() {
1662            return Err(anyhow!("session is not active: {bound_session_id}"));
1663        }
1664        return Ok(ResolvedAttachment {
1665            target: AttachTarget::ExistingSession(session),
1666            matched_entry: Some(entry.clone()),
1667            attach_reason: format!(
1668                "reattached actor {} to existing Heddle session {}",
1669                entry.session_id, bound_session_id
1670            ),
1671            precedence,
1672            winning_rule: "explicit-agent-session".to_string(),
1673        });
1674    }
1675    precedence.push("explicit-agent-session:miss".to_string());
1676
1677    if let Some(session_id) = explicit_heddle_session_id {
1678        precedence.push(format!("explicit-heddle-session:{session_id}:matched"));
1679        ensure_requested_entry_matches_session(requested_entry, session_id)?;
1680        let session = sessions
1681            .get_session(session_id)?
1682            .ok_or_else(|| anyhow!("session not found: {session_id}"))?;
1683        if !session.is_active() {
1684            return Err(anyhow!("session is not active: {session_id}"));
1685        }
1686        return Ok(ResolvedAttachment {
1687            target: AttachTarget::ExistingSession(session),
1688            matched_entry: None,
1689            attach_reason: format!("attached to explicit Heddle session {session_id}"),
1690            precedence,
1691            winning_rule: "explicit-heddle-session".to_string(),
1692        });
1693    }
1694    precedence.push("explicit-heddle-session:miss".to_string());
1695
1696    if let Some(native_actor_key) = probe.native_actor_key.as_deref() {
1697        if let Some(entry) = registry.find_active_by_native_actor_key(native_actor_key)?
1698            && claude_actor_compatible(&entry, probe, repo.root())
1699            && let Some(bound_session_id) = entry.heddle_session_id.clone()
1700        {
1701            precedence.push(format!("native-actor-key:{native_actor_key}:matched"));
1702            let session = sessions
1703                .get_session(&bound_session_id)?
1704                .ok_or_else(|| anyhow!("session not found: {bound_session_id}"))?;
1705            if session.is_active() {
1706                return Ok(ResolvedAttachment {
1707                    target: AttachTarget::ExistingSession(session),
1708                    matched_entry: Some(entry),
1709                    attach_reason: format!(
1710                        "reattached native actor {} to Heddle session {}",
1711                        native_actor_key, bound_session_id
1712                    ),
1713                    precedence,
1714                    winning_rule: "native-actor-key".to_string(),
1715                });
1716            }
1717        }
1718        precedence.push(format!("native-actor-key:{native_actor_key}:miss"));
1719    } else {
1720        precedence.push("native-actor-key:miss".to_string());
1721    }
1722
1723    if let Some(client_instance_id) = client_instance_id {
1724        if let Some(entry) = registry.find_active_by_client_instance_id(client_instance_id)?
1725            && let Some(bound_session_id) = entry.heddle_session_id.clone()
1726        {
1727            precedence.push(format!("client-instance-id:{client_instance_id}:matched"));
1728            let session = sessions
1729                .get_session(&bound_session_id)?
1730                .ok_or_else(|| anyhow!("session not found: {bound_session_id}"))?;
1731            if session.is_active() {
1732                return Ok(ResolvedAttachment {
1733                    target: AttachTarget::ExistingSession(session),
1734                    matched_entry: Some(entry),
1735                    attach_reason: format!(
1736                        "reattached client instance {client_instance_id} to Heddle session {bound_session_id}"
1737                    ),
1738                    precedence,
1739                    winning_rule: "client-instance-id".to_string(),
1740                });
1741            }
1742        }
1743        precedence.push(format!("client-instance-id:{client_instance_id}:miss"));
1744        return Ok(ResolvedAttachment {
1745            target: AttachTarget::CreateNew {
1746                _because_claimed: false,
1747            },
1748            matched_entry: None,
1749            attach_reason: format!(
1750                "started new Heddle session for distinct client instance {client_instance_id}"
1751            ),
1752            precedence,
1753            winning_rule: "create-new-session".to_string(),
1754        });
1755    } else {
1756        precedence.push("client-instance-id:miss".to_string());
1757    }
1758
1759    if probe.native_actor_key.is_some() {
1760        precedence.push("native-instance-key:skipped-strong-native-key".to_string());
1761        return Ok(ResolvedAttachment {
1762            target: AttachTarget::CreateNew {
1763                _because_claimed: false,
1764            },
1765            matched_entry: None,
1766            attach_reason:
1767                "started new Heddle session because no compatible native actor match was found"
1768                    .to_string(),
1769            precedence,
1770            winning_rule: "create-new-session".to_string(),
1771        });
1772    }
1773
1774    if let Some(native_instance_key) = probe.native_instance_key.as_deref() {
1775        if let Some(entry) =
1776            registry.find_active_by_native_instance_key_at_path(native_instance_key, repo.root())?
1777            && claude_actor_compatible(&entry, probe, repo.root())
1778            && let Some(bound_session_id) = entry.heddle_session_id.clone()
1779        {
1780            precedence.push(format!("native-instance-key:{native_instance_key}:matched"));
1781            let session = sessions
1782                .get_session(&bound_session_id)?
1783                .ok_or_else(|| anyhow!("session not found: {bound_session_id}"))?;
1784            if session.is_active() {
1785                return Ok(ResolvedAttachment {
1786                    target: AttachTarget::ExistingSession(session),
1787                    matched_entry: Some(entry),
1788                    attach_reason: format!(
1789                        "reattached native instance {} to Heddle session {}",
1790                        native_instance_key, bound_session_id
1791                    ),
1792                    precedence,
1793                    winning_rule: "native-instance-key".to_string(),
1794                });
1795            }
1796        }
1797        precedence.push(format!("native-instance-key:{native_instance_key}:miss"));
1798    } else {
1799        precedence.push("native-instance-key:miss".to_string());
1800    }
1801
1802    if probe.attach_hints.root_actor
1803        && let Some(current) = sessions.get_current_session()?
1804        && current.is_active()
1805    {
1806        let claimed = session_claimed_by_other(
1807            registry,
1808            &current.id,
1809            requested_entry,
1810            client_instance_id,
1811            probe.native_actor_key.as_deref(),
1812        )?;
1813        if !claimed {
1814            precedence.push(format!("current-worktree-session:{}:matched", current.id));
1815            return Ok(ResolvedAttachment {
1816                target: AttachTarget::ExistingSession(current.clone()),
1817                matched_entry: None,
1818                attach_reason: format!("attached to active worktree Heddle session {}", current.id),
1819                precedence,
1820                winning_rule: "current-worktree-session".to_string(),
1821            });
1822        }
1823        precedence.push(format!("current-worktree-session:{}:claimed", current.id));
1824        return Ok(ResolvedAttachment {
1825            target: AttachTarget::CreateNew {
1826                _because_claimed: true,
1827            },
1828            matched_entry: None,
1829            attach_reason: "started a new Heddle session because the current session was already claimed by another active actor".to_string(),
1830            precedence,
1831            winning_rule: "create-new-session".to_string(),
1832        });
1833    }
1834    precedence.push("current-worktree-session:miss".to_string());
1835
1836    if let Some(claims) = token_claims
1837        && let Some(token_sid) = claims.sid.as_deref()
1838        && let Some(session) = sessions.get_session(token_sid)?
1839        && session.is_active()
1840    {
1841        let claimed = session_claimed_by_other(
1842            registry,
1843            &session.id,
1844            requested_entry,
1845            client_instance_id,
1846            probe.native_actor_key.as_deref(),
1847        )?;
1848        if !claimed {
1849            precedence.push(format!("token-sid:{token_sid}:matched"));
1850            return Ok(ResolvedAttachment {
1851                target: AttachTarget::ExistingSession(session),
1852                matched_entry: None,
1853                attach_reason: format!(
1854                    "attached to Heddle session {token_sid} from auth token sid"
1855                ),
1856                precedence,
1857                winning_rule: "token-sid".to_string(),
1858            });
1859        }
1860        precedence.push(format!("token-sid:{token_sid}:claimed"));
1861        return Ok(ResolvedAttachment {
1862            target: AttachTarget::CreateNew {
1863                _because_claimed: true,
1864            },
1865            matched_entry: None,
1866            attach_reason: "started a new Heddle session because the current session was already claimed by another active actor".to_string(),
1867            precedence,
1868            winning_rule: "create-new-session".to_string(),
1869        });
1870    }
1871    precedence.push("token-sid:miss".to_string());
1872
1873    Ok(ResolvedAttachment {
1874        target: AttachTarget::CreateNew {
1875            _because_claimed: false,
1876        },
1877        matched_entry: None,
1878        attach_reason: "started new Heddle session".to_string(),
1879        precedence,
1880        winning_rule: "create-new-session".to_string(),
1881    })
1882}
1883
1884fn claude_actor_compatible(
1885    entry: &AgentEntry,
1886    probe: &HarnessProbeResult,
1887    repo_root: &Path,
1888) -> bool {
1889    let Some(native_actor_key) = probe.native_actor_key.as_deref() else {
1890        return true;
1891    };
1892    if !native_actor_key.starts_with("claude-code:") {
1893        return true;
1894    }
1895    if native_actor_key.starts_with("claude-code:agent:") {
1896        return entry.native_actor_key.as_deref() == Some(native_actor_key);
1897    }
1898    if let Some(native_instance_key) = probe.native_instance_key.as_deref() {
1899        return entry.native_actor_key.as_deref() == Some(native_actor_key)
1900            && entry.native_instance_key.as_deref() == Some(native_instance_key);
1901    }
1902    let same_repo = entry
1903        .path
1904        .as_ref()
1905        .map(|path| path.canonicalize().unwrap_or_else(|_| path.clone()))
1906        .unwrap_or_default()
1907        == repo_root
1908            .canonicalize()
1909            .unwrap_or_else(|_| repo_root.to_path_buf());
1910    entry.native_actor_key.as_deref() == Some(native_actor_key)
1911        && same_repo
1912        && probe.confidence.unwrap_or_default() >= 0.9
1913}
1914
1915fn decode_token_claims(token: &str) -> Option<TokenClaims> {
1916    let payload = token.split('.').nth(1)?;
1917    let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1918        .decode(payload.as_bytes())
1919        .ok()?;
1920    serde_json::from_slice(&decoded).ok()
1921}
1922
1923fn user_config_token_claims(user_config: &UserConfig) -> Option<TokenClaims> {
1924    user_config
1925        .remote_token()
1926        .and_then(|token| decode_token_claims(&token.id))
1927}
1928
1929#[derive(Debug, Deserialize)]
1930struct TokenClaims {
1931    #[serde(default)]
1932    sid: Option<String>,
1933    #[serde(default)]
1934    agent_provider: Option<String>,
1935    #[serde(default)]
1936    agent_model: Option<String>,
1937}
1938
1939fn should_rotate_segment(session: &objects::object::Session, identity: &ResolvedIdentity) -> bool {
1940    let Some(segment) = session.current_segment() else {
1941        return false;
1942    };
1943    let provider_changed = identity
1944        .provider
1945        .as_deref()
1946        .is_some_and(|provider| provider != segment.provider);
1947    let model_changed = identity
1948        .model
1949        .as_deref()
1950        .is_some_and(|model| model != segment.model);
1951    provider_changed || model_changed
1952}
1953
1954fn thread_id_for_name(repo: &Repository, thread_name: Option<&str>) -> Result<Option<String>> {
1955    let Some(thread_name) = thread_name else {
1956        return Ok(None);
1957    };
1958    Ok(ThreadManager::new(repo.heddle_dir())
1959        .load(thread_name)?
1960        .map(|thread| thread.id))
1961}
1962
1963fn can_create_harness_thread(
1964    repo: &Repository,
1965    target_thread: Option<&str>,
1966    parent_thread: Option<&str>,
1967) -> Result<bool> {
1968    Ok(resolve_harness_thread_base_state(repo, target_thread, parent_thread)?.is_some())
1969}
1970
1971fn resolve_harness_thread_base_state(
1972    repo: &Repository,
1973    target_thread: Option<&str>,
1974    parent_thread: Option<&str>,
1975) -> Result<Option<objects::object::ChangeId>> {
1976    if let Some(head_state) = repo.head()? {
1977        return Ok(Some(head_state));
1978    }
1979
1980    for thread_name in [parent_thread, target_thread].into_iter().flatten() {
1981        if let Some(state) = resolve_named_thread_base_state(repo, thread_name)? {
1982            return Ok(Some(state));
1983        }
1984    }
1985
1986    Ok(None)
1987}
1988
1989fn resolve_named_thread_base_state(
1990    repo: &Repository,
1991    thread_name: &str,
1992) -> Result<Option<objects::object::ChangeId>> {
1993    if let Some(thread) = ThreadManager::new(repo.heddle_dir()).load(thread_name)?
1994        && let Some(state_spec) = thread
1995            .current_state
1996            .as_deref()
1997            .or(Some(thread.base_state.as_str()))
1998        && let Some(state_id) = repo
1999            .resolve_state(state_spec)?
2000            .or_else(|| objects::object::ChangeId::parse(state_spec).ok())
2001    {
2002        return Ok(Some(state_id));
2003    }
2004
2005    Ok(repo.refs().get_thread(thread_name)?)
2006}
2007
2008fn resolve_parent_thread_for_subagent(
2009    repo: &Repository,
2010    probe: &HarnessProbeResult,
2011    current_attached: Option<&str>,
2012) -> Result<Option<String>> {
2013    if let Some(parent_key) = probe.native_parent_actor_key.as_deref() {
2014        let registry = AgentRegistry::new(repo.heddle_dir());
2015        if let Some(entry) = registry.find_active_by_native_actor_key(parent_key)? {
2016            return Ok(Some(entry.thread));
2017        }
2018    }
2019    Ok(current_attached.map(ToString::to_string))
2020}
2021
2022fn preferred_thread_slug(
2023    params: &OpenSessionParams,
2024    probe: &HarnessProbeResult,
2025    identity: &ResolvedIdentity,
2026) -> String {
2027    params
2028        .task
2029        .clone()
2030        .or_else(|| params.summary.clone())
2031        .or_else(|| probe.native_actor_key.as_deref().map(native_key_slug))
2032        .or_else(|| probe.native_instance_key.as_deref().map(native_key_slug))
2033        .or_else(|| identity.harness.clone())
2034        .unwrap_or_else(|| "work".to_string())
2035}
2036
2037fn native_key_slug(value: &str) -> String {
2038    value
2039        .rsplit(':')
2040        .next()
2041        .map(ToString::to_string)
2042        .unwrap_or_else(|| value.to_string())
2043}
2044
2045fn allocate_thread_name(repo: &Repository, base: &str) -> Result<String> {
2046    if ThreadManager::new(repo.heddle_dir()).load(base)?.is_none()
2047        && repo.refs().get_thread(base)?.is_none()
2048    {
2049        return Ok(base.to_string());
2050    }
2051    for idx in 2..1000 {
2052        let candidate = format!("{base}-{idx}");
2053        if ThreadManager::new(repo.heddle_dir())
2054            .load(&candidate)?
2055            .is_none()
2056            && repo.refs().get_thread(&candidate)?.is_none()
2057        {
2058            return Ok(candidate);
2059        }
2060    }
2061    Err(anyhow!(
2062        "could not allocate a unique thread name from '{base}'"
2063    ))
2064}
2065
2066fn default_private_thread_path(repo: &Repository, name: &str) -> PathBuf {
2067    let workspace_root = shared_workspace_root(repo);
2068    let repo_name = workspace_root
2069        .file_name()
2070        .and_then(|name| name.to_str())
2071        .filter(|name| !name.is_empty())
2072        .unwrap_or("heddle");
2073    let parent = workspace_root
2074        .parent()
2075        .map(|path| path.to_path_buf())
2076        .unwrap_or_else(|| workspace_root.to_path_buf());
2077    parent
2078        .join(format!(".{repo_name}-heddle-threads"))
2079        .join(sanitize_name(name))
2080        .join("root")
2081}
2082
2083fn shared_workspace_root(repo: &Repository) -> &Path {
2084    repo.heddle_dir().parent().unwrap_or_else(|| repo.root())
2085}
2086
2087fn sanitize_name(name: &str) -> String {
2088    let mut out = String::new();
2089    let mut last_dash = false;
2090    for ch in name.chars() {
2091        if ch.is_ascii_alphanumeric() {
2092            out.push(ch.to_ascii_lowercase());
2093            last_dash = false;
2094        } else if !last_dash {
2095            out.push('-');
2096            last_dash = true;
2097        }
2098    }
2099    out.trim_matches('-').to_string()
2100}
2101
2102fn resolve_requested_registry_entry(
2103    registry: &AgentRegistry,
2104    agent_session_id: Option<&str>,
2105    client_instance_id: Option<&str>,
2106) -> Result<Option<AgentEntry>> {
2107    if let Some(agent_session_id) = agent_session_id {
2108        let entry = registry
2109            .load(agent_session_id)?
2110            .ok_or_else(|| anyhow!("agent session not found: {agent_session_id}"))?;
2111        if entry.status != AgentStatus::Active {
2112            return Err(anyhow!("agent session is not active: {agent_session_id}"));
2113        }
2114        return Ok(Some(entry));
2115    }
2116
2117    if let Some(client_instance_id) = client_instance_id {
2118        return Ok(registry.find_active_by_client_instance_id(client_instance_id)?);
2119    }
2120
2121    Ok(None)
2122}
2123
2124fn ensure_requested_entry_matches_session(
2125    requested_entry: Option<&AgentEntry>,
2126    heddle_session_id: &str,
2127) -> Result<()> {
2128    if let Some(entry) = requested_entry
2129        && let Some(bound_session_id) = entry.heddle_session_id.as_deref()
2130        && bound_session_id != heddle_session_id
2131    {
2132        return Err(anyhow!(
2133            "requested agent is already bound to a different heddle session: {}",
2134            entry.session_id
2135        ));
2136    }
2137    Ok(())
2138}
2139
2140fn session_claimed_by_other(
2141    registry: &AgentRegistry,
2142    heddle_session_id: &str,
2143    requested_entry: Option<&AgentEntry>,
2144    client_instance_id: Option<&str>,
2145    native_actor_key: Option<&str>,
2146) -> Result<bool> {
2147    if requested_entry.is_none() && client_instance_id.is_none() && native_actor_key.is_none() {
2148        return Ok(false);
2149    }
2150
2151    let Some(existing) = registry.find_active_by_heddle_session_id(heddle_session_id)? else {
2152        return Ok(false);
2153    };
2154    if let Some(requested) = requested_entry {
2155        return Ok(requested.session_id != existing.session_id);
2156    }
2157    if let Some(client_instance_id) = client_instance_id
2158        && existing.client_instance_id.as_deref() == Some(client_instance_id)
2159    {
2160        return Ok(false);
2161    }
2162    if let Some(native_actor_key) = native_actor_key
2163        && existing.native_actor_key.as_deref() == Some(native_actor_key)
2164    {
2165        return Ok(false);
2166    }
2167    Ok(true)
2168}
2169
2170fn find_matching_registry_entry(
2171    registry: &AgentRegistry,
2172    repo: &Repository,
2173    heddle_session_id: &str,
2174    thread_name: Option<&str>,
2175) -> Result<Option<AgentEntry>> {
2176    if let Some(entry) = registry.find_active_by_heddle_session_id(heddle_session_id)? {
2177        return Ok(Some(entry));
2178    }
2179    let canonical_root = repo
2180        .root()
2181        .canonicalize()
2182        .unwrap_or_else(|_| repo.root().to_path_buf());
2183    Ok(registry
2184        .list()?
2185        .into_iter()
2186        .filter(|entry| entry.status == AgentStatus::Active)
2187        .find(|entry| {
2188            entry
2189                .path
2190                .as_ref()
2191                .map(|path| path.canonicalize().unwrap_or_else(|_| path.clone()) == canonical_root)
2192                .unwrap_or(false)
2193                || thread_name.is_some_and(|thread| entry.thread == thread)
2194        }))
2195}
2196
2197fn merged_env_hints(extra: &BTreeMap<String, String>) -> BTreeMap<String, String> {
2198    let mut merged: BTreeMap<String, String> = std::env::vars()
2199        .filter(|(key, _)| inherited_harness_hint(key))
2200        .collect();
2201    for (key, value) in extra {
2202        merged.insert(key.clone(), value.clone());
2203    }
2204    merged
2205}
2206
2207fn inherited_harness_hint(key: &str) -> bool {
2208    if matches!(
2209        key,
2210        "OPENAI_MODEL"
2211            | "ANTHROPIC_MODEL"
2212            | "CLAUDE_MODEL"
2213            | "MODEL"
2214            | "OPENAI_REASONING_EFFORT"
2215            | "REASONING_EFFORT"
2216            | "THINKING_LEVEL"
2217            | "PROMPT_POLICY"
2218    ) {
2219        return false;
2220    }
2221
2222    key.starts_with("HEDDLE_")
2223        || key.starts_with("CODEX_")
2224        || key == "CLAUDECODE"
2225        || key.starts_with("OPENCODE_")
2226}
2227
2228fn to_json_value<T: Serialize>(value: T) -> Result<Value> {
2229    serde_json::to_value(value).map_err(|err| anyhow!(err))
2230}
2231
2232fn normalize_paths<I>(paths: I) -> Vec<String>
2233where
2234    I: IntoIterator<Item = String>,
2235{
2236    let mut ordered = BTreeSet::new();
2237    for path in paths {
2238        let normalized = path.trim().replace('\\', "/");
2239        if !normalized.is_empty() {
2240            ordered.insert(normalized);
2241        }
2242    }
2243    ordered.into_iter().collect()
2244}
2245
2246fn merge_unique_paths<I>(target: &mut Vec<String>, paths: I)
2247where
2248    I: IntoIterator<Item = String>,
2249{
2250    let mut merged: BTreeSet<String> = target.iter().cloned().collect();
2251    merged.extend(paths);
2252    *target = merged.into_iter().collect();
2253}
2254
2255fn max_u64(current: Option<u64>, candidate: u64) -> u64 {
2256    current
2257        .map(|value| value.max(candidate))
2258        .unwrap_or(candidate)
2259}
2260
2261fn max_u32(current: Option<u32>, candidate: u32) -> u32 {
2262    current
2263        .map(|value| value.max(candidate))
2264        .unwrap_or(candidate)
2265}
2266
2267fn merge_usage(target: &mut UsageTotals, incoming: &UsageTotals) {
2268    if let Some(input) = incoming.input_tokens {
2269        target.input_tokens = Some(max_u64(target.input_tokens, input));
2270    }
2271    if let Some(output) = incoming.output_tokens {
2272        target.output_tokens = Some(max_u64(target.output_tokens, output));
2273    }
2274    if let Some(reasoning) = incoming.reasoning_tokens {
2275        target.reasoning_tokens = Some(max_u64(target.reasoning_tokens, reasoning));
2276    }
2277    if let Some(cache_creation) = incoming.cache_creation_tokens {
2278        target.cache_creation_tokens = Some(max_u64(target.cache_creation_tokens, cache_creation));
2279    }
2280    if let Some(cache_read) = incoming.cache_read_tokens {
2281        target.cache_read_tokens = Some(max_u64(target.cache_read_tokens, cache_read));
2282    }
2283    if let Some(tool_calls) = incoming.tool_calls {
2284        target.tool_calls = Some(max_u32(target.tool_calls, tool_calls));
2285    }
2286    if let Some(cost) = incoming.cost_micros_usd {
2287        target.cost_micros_usd = Some(max_u64(target.cost_micros_usd, cost));
2288    }
2289}
2290
2291fn parse_timestamp(value: &str) -> Option<chrono::DateTime<Utc>> {
2292    chrono::DateTime::parse_from_rfc3339(value)
2293        .ok()
2294        .map(|dt| dt.with_timezone(&Utc))
2295}
2296
2297fn transport_from_report(
2298    report: &SessionReportEnvelope,
2299    fallback: HarnessTransport,
2300) -> HarnessTransport {
2301    match report.transport_mode.as_str() {
2302        "spool" => HarnessTransport::Spool,
2303        "direct" => HarnessTransport::Direct,
2304        "end" => HarnessTransport::End,
2305        _ => fallback,
2306    }
2307}
2308
2309fn mark_pending_flush(report: &mut SessionReportEnvelope) {
2310    report.pending_flush = true;
2311    report.report_flush_state = Some("pending-local".to_string());
2312}
2313
2314fn enqueue_report(store: &SessionReportStore, report: &mut SessionReportEnvelope) -> Result<()> {
2315    store.append_outbox(report)?;
2316    report.pending_flush = false;
2317    let flushed_at = Utc::now().to_rfc3339();
2318    report.last_flushed_at = Some(flushed_at);
2319    report.report_flush_state = Some("queued-local".to_string());
2320    store.save(report)?;
2321    Ok(())
2322}
2323
2324fn usage_to_summary(usage: &UsageTotals) -> AgentUsageSummary {
2325    AgentUsageSummary {
2326        input_tokens: usage.input_tokens,
2327        output_tokens: usage.output_tokens,
2328        reasoning_tokens: usage.reasoning_tokens,
2329        tool_calls: usage.tool_calls,
2330        cost_micros_usd: usage.cost_micros_usd,
2331    }
2332}
2333
2334fn transcript_mode_name(mode: HarnessTranscriptMode) -> &'static str {
2335    match mode {
2336        HarnessTranscriptMode::Off => "off",
2337        HarnessTranscriptMode::Summary => "summary",
2338        HarnessTranscriptMode::Full => "full",
2339    }
2340}
2341
2342fn transport_mode_name(mode: HarnessTransport) -> &'static str {
2343    match mode {
2344        HarnessTransport::Spool => "spool",
2345        HarnessTransport::Direct => "direct",
2346        HarnessTransport::End => "end",
2347    }
2348}
2349
2350struct FinalDiff {
2351    changed_paths: Vec<String>,
2352    diff_summary: SessionDiffSummary,
2353    head_state: Option<String>,
2354}
2355
2356fn compute_final_diff(
2357    repo: &Repository,
2358    base_state: Option<&str>,
2359    worktree_baseline: &[WorktreeChangeBaseline],
2360) -> Result<FinalDiff> {
2361    let mut changes: BTreeMap<String, DiffKind> = BTreeMap::new();
2362
2363    let head_state = repo.head()?;
2364    if let (Some(base_spec), Some(head_id)) = (base_state, head_state) {
2365        let base_id = repo
2366            .resolve_state(base_spec)?
2367            .or_else(|| objects::object::ChangeId::parse(base_spec).ok());
2368        if let Some(base_id) = base_id
2369            && base_id != head_id
2370        {
2371            let Some(base_state_obj) = repo.store().get_state(&base_id)? else {
2372                return Err(anyhow!("base state not found: {base_spec}"));
2373            };
2374            let Some(head_state_obj) = repo.store().get_state(&head_id)? else {
2375                return Err(anyhow!("head state not found: {}", head_id.short()));
2376            };
2377            for change in repo.diff_trees(&base_state_obj.tree, &head_state_obj.tree)? {
2378                changes.insert(change.path, change.kind);
2379            }
2380        }
2381    }
2382
2383    let baseline_paths: BTreeSet<(String, String)> = worktree_baseline
2384        .iter()
2385        .map(|change| (change.path.clone(), change.kind.clone()))
2386        .collect();
2387    for (path, kind) in collect_worktree_changes(repo)? {
2388        let kind_name = diff_kind_name(kind);
2389        if !baseline_paths.contains(&(path.clone(), kind_name.to_string())) {
2390            changes.insert(path, kind);
2391        }
2392    }
2393
2394    let diff_summary = SessionDiffSummary {
2395        changed_file_count: changes.len() as u32,
2396        added_files: changes
2397            .values()
2398            .filter(|kind| **kind == DiffKind::Added)
2399            .count() as u32,
2400        modified_files: changes
2401            .values()
2402            .filter(|kind| **kind == DiffKind::Modified)
2403            .count() as u32,
2404        deleted_files: changes
2405            .values()
2406            .filter(|kind| **kind == DiffKind::Deleted)
2407            .count() as u32,
2408    };
2409
2410    Ok(FinalDiff {
2411        changed_paths: changes.into_keys().collect(),
2412        diff_summary,
2413        head_state: head_state.map(|id| id.to_string_full()),
2414    })
2415}
2416
2417fn capture_worktree_change_snapshot(repo: &Repository) -> Result<Vec<WorktreeChangeBaseline>> {
2418    Ok(collect_worktree_changes(repo)?
2419        .into_iter()
2420        .map(|(path, kind)| WorktreeChangeBaseline {
2421            path,
2422            kind: diff_kind_name(kind).to_string(),
2423        })
2424        .collect())
2425}
2426
2427fn collect_worktree_changes(repo: &Repository) -> Result<BTreeMap<String, DiffKind>> {
2428    let status_options = worktree_status_options(Some(repo.config()));
2429    let worktree_tree = match repo.current_state()? {
2430        Some(state) => repo.store().get_tree(&state.tree)?.unwrap_or_default(),
2431        None => Tree::new(),
2432    };
2433    let status = repo.compare_worktree_cached_with_options(&worktree_tree, &status_options)?;
2434    let mut changes = BTreeMap::new();
2435    for path in status.added {
2436        changes.insert(path.display().to_string(), DiffKind::Added);
2437    }
2438    for path in status.modified {
2439        changes.insert(path.display().to_string(), DiffKind::Modified);
2440    }
2441    for path in status.deleted {
2442        changes.insert(path.display().to_string(), DiffKind::Deleted);
2443    }
2444    Ok(changes)
2445}
2446
2447fn diff_kind_name(kind: DiffKind) -> &'static str {
2448    match kind {
2449        DiffKind::Added => "added",
2450        DiffKind::Modified => "modified",
2451        DiffKind::Deleted => "deleted",
2452        DiffKind::Unchanged => "unchanged",
2453    }
2454}
2455
2456struct SessionReportStore {
2457    dir: PathBuf,
2458}
2459
2460impl SessionReportStore {
2461    fn new(repo_root: &Path) -> Self {
2462        Self {
2463            dir: repo_root.join(".heddle/state").join("session-reports"),
2464        }
2465    }
2466
2467    fn session_path(&self, heddle_session_id: &str) -> PathBuf {
2468        self.dir.join(format!("{heddle_session_id}.json"))
2469    }
2470
2471    fn outbox_path(&self) -> PathBuf {
2472        self.dir.join("outbox.jsonl")
2473    }
2474
2475    fn load(&self, heddle_session_id: &str) -> Result<Option<SessionReportEnvelope>> {
2476        let path = self.session_path(heddle_session_id);
2477        if !path.exists() {
2478            return Ok(None);
2479        }
2480        let bytes = fs::read(path)?;
2481        Ok(Some(serde_json::from_slice(&bytes)?))
2482    }
2483
2484    fn save(&self, report: &SessionReportEnvelope) -> Result<()> {
2485        fs::create_dir_all(&self.dir)?;
2486        let path = self.session_path(&report.heddle_session_id);
2487        let bytes = serde_json::to_vec_pretty(report)?;
2488        write_file_atomic(&path, &bytes)?;
2489        Ok(())
2490    }
2491
2492    fn append_outbox(&self, report: &SessionReportEnvelope) -> Result<()> {
2493        fs::create_dir_all(&self.dir)?;
2494        let mut file = OpenOptions::new()
2495            .create(true)
2496            .append(true)
2497            .open(self.outbox_path())?;
2498        serde_json::to_writer(&mut file, report)?;
2499        file.write_all(b"\n")?;
2500        file.flush()?;
2501        Ok(())
2502    }
2503
2504    fn list_pending(&self) -> Result<Vec<String>> {
2505        if !self.dir.exists() {
2506            return Ok(Vec::new());
2507        }
2508        let mut ids = Vec::new();
2509        for entry in fs::read_dir(&self.dir)? {
2510            let entry = entry?;
2511            let path = entry.path();
2512            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
2513                continue;
2514            }
2515            let bytes = fs::read(&path)?;
2516            let report: SessionReportEnvelope = serde_json::from_slice(&bytes)?;
2517            if report.pending_flush {
2518                ids.push(report.heddle_session_id);
2519            }
2520        }
2521        ids.sort();
2522        Ok(ids)
2523    }
2524}
2525
2526#[derive(Debug, Deserialize)]
2527struct BridgeRequest {
2528    #[serde(default)]
2529    id: Option<String>,
2530    method: String,
2531    #[serde(default)]
2532    params: Value,
2533}
2534
2535#[derive(Debug, Serialize)]
2536struct BridgeResponse {
2537    #[serde(default)]
2538    id: Option<String>,
2539    ok: bool,
2540    #[serde(skip_serializing_if = "Option::is_none")]
2541    result: Option<Value>,
2542    #[serde(skip_serializing_if = "Option::is_none")]
2543    error: Option<BridgeError>,
2544}
2545
2546impl BridgeResponse {
2547    fn ok(id: Option<String>, result: Value) -> Self {
2548        Self {
2549            id,
2550            ok: true,
2551            result: Some(result),
2552            error: None,
2553        }
2554    }
2555
2556    fn error(id: Option<String>, code: impl Into<String>, message: impl Into<String>) -> Self {
2557        Self {
2558            id,
2559            ok: false,
2560            result: None,
2561            error: Some(BridgeError {
2562                code: code.into(),
2563                message: message.into(),
2564            }),
2565        }
2566    }
2567}
2568
2569#[derive(Debug, Serialize)]
2570struct BridgeError {
2571    code: String,
2572    message: String,
2573}
2574
2575#[derive(Debug, Clone, Deserialize, Default)]
2576struct OpenSessionParams {
2577    #[serde(default)]
2578    heddle_session_id: Option<String>,
2579    #[serde(default)]
2580    agent_session_id: Option<String>,
2581    #[serde(default)]
2582    client_instance_id: Option<String>,
2583    #[serde(default)]
2584    thread: Option<String>,
2585    #[serde(default)]
2586    task: Option<String>,
2587    #[serde(default)]
2588    summary: Option<String>,
2589    #[serde(default)]
2590    harness: Option<String>,
2591    #[serde(default)]
2592    provider: Option<String>,
2593    #[serde(default)]
2594    model: Option<String>,
2595    #[serde(default)]
2596    thinking_level: Option<String>,
2597    #[serde(default)]
2598    policy: Option<String>,
2599    #[serde(default)]
2600    transport: Option<HarnessTransport>,
2601    #[serde(default)]
2602    transcript_mode: Option<HarnessTranscriptMode>,
2603    #[serde(default)]
2604    argv: Option<Vec<String>>,
2605    #[serde(default)]
2606    env_hints: BTreeMap<String, String>,
2607    #[serde(default)]
2608    probe_metadata: BTreeMap<String, String>,
2609}
2610
2611#[derive(Debug, Clone, Deserialize, Default)]
2612struct UpdateProgressParams {
2613    heddle_session_id: String,
2614    #[serde(default)]
2615    status: Option<String>,
2616    #[serde(default)]
2617    message: Option<String>,
2618    #[serde(default)]
2619    completed_steps: Option<u32>,
2620    #[serde(default)]
2621    total_steps: Option<u32>,
2622    #[serde(default)]
2623    touched_paths: Vec<String>,
2624    #[serde(default)]
2625    summary: Option<String>,
2626    #[serde(default)]
2627    harness: Option<String>,
2628    #[serde(default)]
2629    provider: Option<String>,
2630    #[serde(default)]
2631    model: Option<String>,
2632    #[serde(default)]
2633    thinking_level: Option<String>,
2634    #[serde(default)]
2635    policy: Option<String>,
2636    #[serde(default)]
2637    argv: Option<Vec<String>>,
2638    #[serde(default)]
2639    env_hints: BTreeMap<String, String>,
2640    #[serde(default)]
2641    probe_metadata: BTreeMap<String, String>,
2642}
2643
2644#[derive(Debug, Clone, Deserialize, Default)]
2645struct RecordUsageParams {
2646    heddle_session_id: String,
2647    #[serde(default)]
2648    input_tokens: Option<u64>,
2649    #[serde(default)]
2650    output_tokens: Option<u64>,
2651    #[serde(default)]
2652    reasoning_tokens: Option<u64>,
2653    #[serde(default)]
2654    cache_creation_tokens: Option<u64>,
2655    #[serde(default)]
2656    cache_read_tokens: Option<u64>,
2657    #[serde(default)]
2658    tool_calls: Option<u32>,
2659    #[serde(default)]
2660    cost_micros_usd: Option<u64>,
2661}
2662
2663#[derive(Debug, Clone, Deserialize, Default)]
2664struct RecordTouchedPathsParams {
2665    heddle_session_id: String,
2666    #[serde(default)]
2667    paths: Vec<String>,
2668}
2669
2670#[derive(Debug, Clone, Deserialize, Default)]
2671struct CloseSessionParams {
2672    heddle_session_id: String,
2673    #[serde(default)]
2674    outcome: Option<String>,
2675    #[serde(default)]
2676    summary: Option<String>,
2677    #[serde(default)]
2678    transcript_refs: Option<Vec<TranscriptAttachmentRef>>,
2679    #[serde(default)]
2680    transport: Option<HarnessTransport>,
2681}
2682
2683#[derive(Debug, Clone, Deserialize, Default)]
2684struct FlushReportsParams {
2685    #[serde(default)]
2686    heddle_session_id: Option<String>,
2687}
2688
2689#[derive(Debug, Serialize)]
2690struct OpenSessionResult {
2691    heddle_session_id: String,
2692    heddle_segment_id: Option<String>,
2693    agent_session_id: Option<String>,
2694    created_session: bool,
2695    harness: Option<String>,
2696    provider: Option<String>,
2697    model: Option<String>,
2698    thinking_level: Option<String>,
2699    report_flush_state: Option<String>,
2700    attach_reason: Option<String>,
2701}
2702
2703#[derive(Debug, Serialize)]
2704struct SessionMutationResult {
2705    heddle_session_id: String,
2706    heddle_segment_id: Option<String>,
2707    report_flush_state: Option<String>,
2708}
2709
2710#[derive(Debug, Serialize)]
2711struct CloseSessionResult {
2712    heddle_session_id: String,
2713    changed_paths: Vec<String>,
2714    diff_summary: SessionDiffSummary,
2715    report_flush_state: Option<String>,
2716}
2717
2718#[derive(Debug, Serialize)]
2719struct FlushReportsResult {
2720    flushed: usize,
2721}
2722
2723#[cfg(test)]
2724mod tests {
2725    use super::*;
2726
2727    fn init_repo() -> (tempfile::TempDir, Repository) {
2728        let temp = tempfile::TempDir::new().unwrap();
2729        let repo = Repository::init_default(temp.path()).unwrap();
2730        (temp, repo)
2731    }
2732
2733    #[test]
2734    fn inherited_harness_hints_exclude_ambient_model_identity() {
2735        assert!(!inherited_harness_hint("OPENAI_MODEL"));
2736        assert!(!inherited_harness_hint("ANTHROPIC_MODEL"));
2737        assert!(!inherited_harness_hint("CLAUDE_MODEL"));
2738        assert!(!inherited_harness_hint("MODEL"));
2739        assert!(!inherited_harness_hint("OPENAI_REASONING_EFFORT"));
2740        assert!(inherited_harness_hint("HEDDLE_AGENT_MODEL"));
2741        assert!(inherited_harness_hint("CODEX_SANDBOX"));
2742        assert!(inherited_harness_hint("CLAUDECODE"));
2743    }
2744
2745    #[test]
2746    fn open_session_creates_or_attaches() {
2747        let (_temp, repo) = init_repo();
2748        let user_config = UserConfig::default();
2749        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
2750
2751        let created = runtime
2752            .open_session(OpenSessionParams {
2753                harness: Some("codex".to_string()),
2754                provider: Some("openai".to_string()),
2755                model: Some("gpt-5.4".to_string()),
2756                ..OpenSessionParams::default()
2757            })
2758            .unwrap();
2759        assert!(created.created_session);
2760
2761        let attached = runtime
2762            .open_session(OpenSessionParams {
2763                harness: Some("codex".to_string()),
2764                provider: Some("openai".to_string()),
2765                model: Some("gpt-5.4".to_string()),
2766                ..OpenSessionParams::default()
2767            })
2768            .unwrap();
2769        assert!(!attached.created_session);
2770        assert_eq!(created.heddle_session_id, attached.heddle_session_id);
2771    }
2772
2773    #[test]
2774    fn same_client_instance_reattaches_to_its_existing_session() {
2775        let (_temp, repo) = init_repo();
2776        let user_config = UserConfig::default();
2777        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
2778
2779        let first = runtime
2780            .open_session(OpenSessionParams {
2781                client_instance_id: Some("client-a".to_string()),
2782                harness: Some("codex".to_string()),
2783                provider: Some("openai".to_string()),
2784                model: Some("gpt-5.4".to_string()),
2785                ..OpenSessionParams::default()
2786            })
2787            .unwrap();
2788        let second = runtime
2789            .open_session(OpenSessionParams {
2790                client_instance_id: Some("client-b".to_string()),
2791                harness: Some("codex".to_string()),
2792                provider: Some("openai".to_string()),
2793                model: Some("gpt-5.4".to_string()),
2794                ..OpenSessionParams::default()
2795            })
2796            .unwrap();
2797        let reopened = runtime
2798            .open_session(OpenSessionParams {
2799                client_instance_id: Some("client-a".to_string()),
2800                harness: Some("codex".to_string()),
2801                provider: Some("openai".to_string()),
2802                model: Some("gpt-5.4".to_string()),
2803                ..OpenSessionParams::default()
2804            })
2805            .unwrap();
2806
2807        assert_ne!(first.heddle_session_id, second.heddle_session_id);
2808        assert_eq!(first.heddle_session_id, reopened.heddle_session_id);
2809        assert_eq!(first.agent_session_id, reopened.agent_session_id);
2810    }
2811
2812    #[test]
2813    fn different_client_instances_do_not_share_the_current_session() {
2814        let (_temp, repo) = init_repo();
2815        let user_config = UserConfig::default();
2816        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
2817
2818        let first = runtime
2819            .open_session(OpenSessionParams {
2820                client_instance_id: Some("client-a".to_string()),
2821                harness: Some("codex".to_string()),
2822                provider: Some("openai".to_string()),
2823                model: Some("gpt-5.4".to_string()),
2824                ..OpenSessionParams::default()
2825            })
2826            .unwrap();
2827        let second = runtime
2828            .open_session(OpenSessionParams {
2829                client_instance_id: Some("client-b".to_string()),
2830                harness: Some("codex".to_string()),
2831                provider: Some("openai".to_string()),
2832                model: Some("gpt-5.4".to_string()),
2833                ..OpenSessionParams::default()
2834            })
2835            .unwrap();
2836
2837        assert_ne!(first.heddle_session_id, second.heddle_session_id);
2838        assert_ne!(first.agent_session_id, second.agent_session_id);
2839    }
2840
2841    #[test]
2842    fn provider_model_change_creates_segment() {
2843        let (_temp, repo) = init_repo();
2844        let user_config = UserConfig::default();
2845        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
2846
2847        let opened = runtime
2848            .open_session(OpenSessionParams {
2849                harness: Some("claude-code".to_string()),
2850                provider: Some("anthropic".to_string()),
2851                model: Some("claude-sonnet".to_string()),
2852                ..OpenSessionParams::default()
2853            })
2854            .unwrap();
2855        runtime
2856            .update_progress(UpdateProgressParams {
2857                heddle_session_id: opened.heddle_session_id.clone(),
2858                provider: Some("openai".to_string()),
2859                model: Some("gpt-5.4".to_string()),
2860                ..UpdateProgressParams::default()
2861            })
2862            .unwrap();
2863
2864        let report = runtime
2865            .reports
2866            .load(&opened.heddle_session_id)
2867            .unwrap()
2868            .unwrap();
2869        let expected_segment = format!("{}-seg-2", opened.heddle_session_id);
2870        assert_eq!(
2871            report.heddle_segment_id.as_deref(),
2872            Some(expected_segment.as_str())
2873        );
2874    }
2875
2876    #[test]
2877    fn close_session_captures_changed_paths_from_status_and_hints() {
2878        let (temp, repo) = init_repo();
2879        let config = UserConfig::default();
2880        let mut runtime = HarnessBridgeRuntime::new(repo, config);
2881
2882        let opened = runtime
2883            .open_session(OpenSessionParams {
2884                harness: Some("codex".to_string()),
2885                provider: Some("openai".to_string()),
2886                model: Some("gpt-5.4".to_string()),
2887                ..OpenSessionParams::default()
2888            })
2889            .unwrap();
2890        std::fs::write(temp.path().join("src.txt"), "hello\n").unwrap();
2891        runtime
2892            .record_touched_paths(RecordTouchedPathsParams {
2893                heddle_session_id: opened.heddle_session_id.clone(),
2894                paths: vec!["src.txt".to_string(), "notes.md".to_string()],
2895            })
2896            .unwrap();
2897        let closed = runtime
2898            .close_session(CloseSessionParams {
2899                heddle_session_id: opened.heddle_session_id.clone(),
2900                outcome: Some("completed".to_string()),
2901                ..CloseSessionParams::default()
2902            })
2903            .unwrap();
2904        let report = runtime
2905            .reports
2906            .load(&opened.heddle_session_id)
2907            .unwrap()
2908            .unwrap();
2909        assert!(closed.changed_paths.iter().any(|path| path == "src.txt"));
2910        assert!(!closed.changed_paths.iter().any(|path| path == "notes.md"));
2911        assert!(report.touched_paths.iter().any(|path| path == "src.txt"));
2912        assert!(report.touched_paths.iter().any(|path| path == "notes.md"));
2913        assert_eq!(
2914            closed.diff_summary.changed_file_count,
2915            closed.changed_paths.len() as u32
2916        );
2917    }
2918
2919    #[test]
2920    fn flush_reports_moves_pending_report_to_outbox() {
2921        let (_temp, repo) = init_repo();
2922        let user_config = UserConfig::default();
2923        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
2924
2925        let opened = runtime
2926            .open_session(OpenSessionParams {
2927                harness: Some("codex".to_string()),
2928                provider: Some("openai".to_string()),
2929                model: Some("gpt-5.4".to_string()),
2930                ..OpenSessionParams::default()
2931            })
2932            .unwrap();
2933        let flushed = runtime
2934            .flush_reports(FlushReportsParams {
2935                heddle_session_id: Some(opened.heddle_session_id.clone()),
2936            })
2937            .unwrap();
2938        assert_eq!(flushed.flushed, 1);
2939        let report = runtime
2940            .reports
2941            .load(&opened.heddle_session_id)
2942            .unwrap()
2943            .unwrap();
2944        assert!(!report.pending_flush);
2945        assert_eq!(report.report_flush_state.as_deref(), Some("queued-local"));
2946        assert!(runtime.reports.outbox_path().exists());
2947    }
2948
2949    #[test]
2950    fn explicit_overrides_beat_fingerprint_and_user_defaults() {
2951        let (_temp, repo) = init_repo();
2952        let mut user_config = UserConfig::default();
2953        user_config.harness.harnesses.insert(
2954            "codex".to_string(),
2955            UserHarnessOverride {
2956                provider: Some("openai".to_string()),
2957                model: Some("gpt-default".to_string()),
2958                thinking_level: Some("medium".to_string()),
2959                policy: Some("default".to_string()),
2960            },
2961        );
2962        let identity = resolve_identity(
2963            &repo,
2964            &user_config,
2965            IdentityHints {
2966                harness: Some("codex".to_string()),
2967                provider: Some("openai".to_string()),
2968                model: Some("gpt-5.4".to_string()),
2969                thinking_level: Some("high".to_string()),
2970                policy: Some("custom".to_string()),
2971                probe: HarnessProbeResult::default(),
2972            },
2973        )
2974        .unwrap();
2975        assert_eq!(identity.model.as_deref(), Some("gpt-5.4"));
2976        assert_eq!(identity.thinking_level.as_deref(), Some("high"));
2977        assert_eq!(identity.policy.as_deref(), Some("custom"));
2978    }
2979
2980    #[test]
2981    fn transcript_mode_defaults_to_off_and_keeps_refs_empty() {
2982        let (_temp, repo) = init_repo();
2983        let user_config = UserConfig::default();
2984        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
2985
2986        let opened = runtime
2987            .open_session(OpenSessionParams {
2988                harness: Some("codex".to_string()),
2989                provider: Some("openai".to_string()),
2990                model: Some("gpt-5.4".to_string()),
2991                ..OpenSessionParams::default()
2992            })
2993            .unwrap();
2994        let report = runtime
2995            .reports
2996            .load(&opened.heddle_session_id)
2997            .unwrap()
2998            .unwrap();
2999        assert_eq!(report.transcript_mode, "off");
3000        assert!(report.transcript_refs.is_empty());
3001    }
3002
3003    #[test]
3004    fn codex_thread_probe_reattaches_same_actor() {
3005        let (_temp, repo) = init_repo();
3006        let user_config = UserConfig::default();
3007        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
3008
3009        let first = runtime
3010            .open_session(OpenSessionParams {
3011                harness: Some("codex".to_string()),
3012                probe_metadata: BTreeMap::from([
3013                    ("thread_id".to_string(), "thr_123".to_string()),
3014                    ("client_name".to_string(), "codex-tui".to_string()),
3015                ]),
3016                ..OpenSessionParams::default()
3017            })
3018            .unwrap();
3019        let second = runtime
3020            .open_session(OpenSessionParams {
3021                harness: Some("codex".to_string()),
3022                probe_metadata: BTreeMap::from([
3023                    ("thread_id".to_string(), "thr_123".to_string()),
3024                    ("client_name".to_string(), "codex-tui".to_string()),
3025                ]),
3026                ..OpenSessionParams::default()
3027            })
3028            .unwrap();
3029
3030        assert_eq!(first.agent_session_id, second.agent_session_id);
3031        assert_eq!(first.heddle_session_id, second.heddle_session_id);
3032    }
3033
3034    #[test]
3035    fn opencode_child_session_creates_distinct_actor_with_parent_key() {
3036        let (_temp, repo) = init_repo();
3037        let user_config = UserConfig::default();
3038        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
3039
3040        let root = runtime
3041            .open_session(OpenSessionParams {
3042                harness: Some("opencode".to_string()),
3043                probe_metadata: BTreeMap::from([("session_id".to_string(), "root-1".to_string())]),
3044                ..OpenSessionParams::default()
3045            })
3046            .unwrap();
3047        let child = runtime
3048            .open_session(OpenSessionParams {
3049                harness: Some("opencode".to_string()),
3050                probe_metadata: BTreeMap::from([
3051                    ("session_id".to_string(), "child-1".to_string()),
3052                    ("parent_id".to_string(), "root-1".to_string()),
3053                ]),
3054                ..OpenSessionParams::default()
3055            })
3056            .unwrap();
3057
3058        assert_ne!(root.agent_session_id, child.agent_session_id);
3059        let report = runtime
3060            .reports
3061            .load(&child.heddle_session_id)
3062            .unwrap()
3063            .unwrap();
3064        assert_eq!(
3065            report.native_parent_actor_key.as_deref(),
3066            Some("opencode:session:root-1")
3067        );
3068    }
3069
3070    #[test]
3071    fn claude_resume_with_new_session_id_does_not_steal_existing_actor() {
3072        let (_temp, repo) = init_repo();
3073        let user_config = UserConfig::default();
3074        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
3075
3076        let first = runtime
3077            .open_session(OpenSessionParams {
3078                harness: Some("claude-code".to_string()),
3079                probe_metadata: BTreeMap::from([
3080                    ("session_id".to_string(), "sess-old".to_string()),
3081                    (
3082                        "transcript_path".to_string(),
3083                        "/tmp/claude/session-a.jsonl".to_string(),
3084                    ),
3085                ]),
3086                ..OpenSessionParams::default()
3087            })
3088            .unwrap();
3089        let resumed = runtime
3090            .open_session(OpenSessionParams {
3091                harness: Some("claude-code".to_string()),
3092                probe_metadata: BTreeMap::from([
3093                    ("session_id".to_string(), "sess-new".to_string()),
3094                    (
3095                        "transcript_path".to_string(),
3096                        "/tmp/claude/session-a.jsonl".to_string(),
3097                    ),
3098                ]),
3099                ..OpenSessionParams::default()
3100            })
3101            .unwrap();
3102
3103        assert_ne!(first.agent_session_id, resumed.agent_session_id);
3104        assert_ne!(first.heddle_session_id, resumed.heddle_session_id);
3105    }
3106
3107    #[test]
3108    fn explicit_claude_harness_beats_generic_session_id_probe_match() {
3109        let (_temp, repo) = init_repo();
3110        let user_config = UserConfig::default();
3111        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
3112
3113        let opened = runtime
3114            .open_session(OpenSessionParams {
3115                harness: Some("claude-code".to_string()),
3116                probe_metadata: BTreeMap::from([
3117                    ("session_id".to_string(), "claude-sess-1".to_string()),
3118                    ("hook_event".to_string(), "SubagentStop".to_string()),
3119                ]),
3120                ..OpenSessionParams::default()
3121            })
3122            .unwrap();
3123        let report = runtime
3124            .reports
3125            .load(&opened.heddle_session_id)
3126            .unwrap()
3127            .unwrap();
3128        assert_eq!(
3129            report.native_actor_key.as_deref(),
3130            Some("claude-code:session:claude-sess-1")
3131        );
3132        assert_eq!(report.harness.harness.as_deref(), Some("claude-code"));
3133    }
3134
3135    #[test]
3136    fn same_native_actor_key_reuses_existing_actor_after_tentative_session_creation() {
3137        let (_temp, repo) = init_repo();
3138        let user_config = UserConfig::default();
3139        let runtime = HarnessBridgeRuntime::new(repo, user_config);
3140        let principal = runtime.repo.get_principal().unwrap();
3141        let mut sessions = SessionManager::new(runtime.repo.root());
3142        let existing_session = sessions
3143            .start_session(
3144                principal.clone(),
3145                "anthropic".to_string(),
3146                "claude-opus-4-7[1m]".to_string(),
3147                None,
3148            )
3149            .unwrap();
3150        let tentative_session = sessions
3151            .start_session(
3152                principal,
3153                "anthropic".to_string(),
3154                "claude-opus-4-7[1m]".to_string(),
3155                None,
3156            )
3157            .unwrap();
3158
3159        let registry = AgentRegistry::new(runtime.repo.heddle_dir());
3160        let existing_entry = registry
3161            .create_generated_entry(|session_id| {
3162                Ok(AgentEntry {
3163                    session_id: session_id.to_string(),
3164                    client_instance_id: None,
3165                    native_actor_key: Some(
3166                        "claude-code:session:282396d3-554a-48aa-a9a8-8d1f0bd15fa5".to_string(),
3167                    ),
3168                    native_parent_actor_key: None,
3169                    native_instance_key: Some(
3170                        "claude-code:transcript:/tmp/claude/282396d3.jsonl".to_string(),
3171                    ),
3172                    heddle_session_id: Some(existing_session.id.clone()),
3173                    thread_id: None,
3174                    thread: "detached".to_string(),
3175                    pid: Some(std::process::id()),
3176                    boot_id: None,
3177                    liveness_path: None,
3178                    heartbeat_at: Some(Utc::now()),
3179                    anchor_state: None,
3180                    anchor_root: None,
3181                    reservation_token: Some(objects::store::generate_agent_id()),
3182                    path: Some(runtime.repo.root().to_path_buf()),
3183                    base_state: String::new(),
3184                    started_at: Utc::now(),
3185                    provider: Some("anthropic".to_string()),
3186                    model: Some("claude-opus-4-7[1m]".to_string()),
3187                    harness: Some("claude-code".to_string()),
3188                    thinking_level: None,
3189                    usage_summary: AgentUsageSummary::default(),
3190                    last_progress_at: None,
3191                    report_flush_state: Some("pending-local".to_string()),
3192                    attach_reason: None,
3193                    attach_precedence: vec![],
3194                    winning_attach_rule: None,
3195                    probe_source: Some("hook_payload".to_string()),
3196                    probe_confidence: Some(1.0),
3197                    status: AgentStatus::Active,
3198                    completed_at: None,
3199                    context_queries: vec![],
3200                })
3201            })
3202            .unwrap();
3203
3204        let probe = HarnessProbeResult {
3205            harness: Some("claude-code".to_string()),
3206            provider: Some("anthropic".to_string()),
3207            model: Some("claude-opus-4-7[1m]".to_string()),
3208            native_actor_key: Some(
3209                "claude-code:session:282396d3-554a-48aa-a9a8-8d1f0bd15fa5".to_string(),
3210            ),
3211            native_instance_key: Some(
3212                "claude-code:transcript:/tmp/claude/282396d3.jsonl".to_string(),
3213            ),
3214            probe_source: Some("hook_payload".to_string()),
3215            confidence: Some(1.0),
3216            ..HarnessProbeResult::default()
3217        };
3218        let identity = ResolvedIdentity {
3219            harness: Some("claude-code".to_string()),
3220            provider: Some("anthropic".to_string()),
3221            model: Some("claude-opus-4-7[1m]".to_string()),
3222            thinking_level: None,
3223            policy: None,
3224        };
3225        let mut attach = ResolvedAttachment {
3226            target: AttachTarget::CreateNew {
3227                _because_claimed: false,
3228            },
3229            matched_entry: None,
3230            attach_reason:
3231                "started new Heddle session because no compatible native actor match was found"
3232                    .to_string(),
3233            precedence: vec!["native-actor-key:miss".to_string()],
3234            winning_rule: "create-new-session".to_string(),
3235        };
3236
3237        let resolved_entry = runtime
3238            .ensure_registry_entry(RegistryEntryRequest {
3239                heddle_session_id: &tentative_session.id,
3240                thread_name: None,
3241                thread_id: None,
3242                identity: &identity,
3243                probe: &probe,
3244                attach: &attach,
3245                client_instance_id: None,
3246                requested_entry: None,
3247            })
3248            .unwrap();
3249        assert_eq!(resolved_entry.session_id, existing_entry.session_id);
3250        assert_eq!(
3251            resolved_entry.heddle_session_id.as_deref(),
3252            Some(existing_session.id.as_str())
3253        );
3254
3255        let (canonical_session, owns_session) = runtime
3256            .reuse_canonical_actor_session(
3257                &mut sessions,
3258                CanonicalActorSessionRequest {
3259                    tentative_session: tentative_session.clone(),
3260                    tentative_owns_session: true,
3261                    entry: &resolved_entry,
3262                    probe: &probe,
3263                    attach: &mut attach,
3264                },
3265            )
3266            .unwrap();
3267        assert_eq!(canonical_session.id, existing_session.id);
3268        assert!(!owns_session);
3269        assert!(
3270            attach
3271                .precedence
3272                .iter()
3273                .any(|step| step.starts_with("post-create-native-actor-key:"))
3274        );
3275        assert_eq!(attach.winning_rule, "native-actor-key-post-create");
3276        assert!(
3277            !sessions
3278                .get_session(&tentative_session.id)
3279                .unwrap()
3280                .unwrap()
3281                .is_active()
3282        );
3283    }
3284
3285    #[test]
3286    fn close_session_does_not_blame_preexisting_dirty_worktree() {
3287        let (temp, repo) = init_repo();
3288        std::fs::write(temp.path().join("preexisting.txt"), "already dirty\n").unwrap();
3289        let user_config = UserConfig::default();
3290        let mut runtime = HarnessBridgeRuntime::new(repo, user_config);
3291
3292        let opened = runtime
3293            .open_session(OpenSessionParams {
3294                harness: Some("claude-code".to_string()),
3295                provider: Some("anthropic".to_string()),
3296                model: Some("claude-opus-4-7[1m]".to_string()),
3297                ..OpenSessionParams::default()
3298            })
3299            .unwrap();
3300        let closed = runtime
3301            .close_session(CloseSessionParams {
3302                heddle_session_id: opened.heddle_session_id.clone(),
3303                outcome: Some("completed".to_string()),
3304                ..CloseSessionParams::default()
3305            })
3306            .unwrap();
3307        let report = runtime
3308            .reports
3309            .load(&opened.heddle_session_id)
3310            .unwrap()
3311            .unwrap();
3312
3313        assert!(
3314            report
3315                .worktree_changes_at_open
3316                .iter()
3317                .any(|change| change.path == "preexisting.txt")
3318        );
3319        assert!(
3320            !closed
3321                .changed_paths
3322                .iter()
3323                .any(|path| path == "preexisting.txt")
3324        );
3325        assert_eq!(closed.diff_summary.changed_file_count, 0);
3326    }
3327
3328    #[test]
3329    fn relay_claude_stop_captures_state_with_agent_attribution() {
3330        let (temp, repo) = init_repo();
3331        let repo_root = repo.root().to_path_buf();
3332
3333        // Establish HEAD with an initial snapshot.
3334        std::fs::write(repo_root.join("seed.txt"), b"hello").unwrap();
3335        let _ = repo.snapshot(Some("seed".into()), None).unwrap();
3336
3337        // Make a dirty change that the Stop hook should capture.
3338        std::fs::write(repo_root.join("seed.txt"), b"hello, heddle").unwrap();
3339
3340        drop(repo);
3341
3342        let fresh_repo = Repository::open(temp.path()).unwrap();
3343        let user_config = UserConfig::default();
3344        let mut runtime = HarnessBridgeRuntime::new(fresh_repo, user_config);
3345        let payload = serde_json::json!({
3346            "session_id": "claude-sess-123",
3347            "transcript_path": "/tmp/claude/x.jsonl",
3348            "model": {
3349                "id": "claude-opus-4-7",
3350                "display_name": "Claude Opus 4.7",
3351            },
3352            "message": "hook-driven capture test",
3353            "hook_event_name": "Stop",
3354        });
3355        relay_claude(&mut runtime, "Stop", &payload).unwrap();
3356        drop(runtime);
3357
3358        let verify = Repository::open(temp.path()).unwrap();
3359        let head_id = verify.head().unwrap().expect("HEAD after Stop capture");
3360        let state = verify
3361            .store()
3362            .get_state(&head_id)
3363            .unwrap()
3364            .expect("state for HEAD");
3365        let agent = state.attribution.agent.expect("agent attribution on state");
3366        assert_eq!(agent.provider, "anthropic");
3367        assert_eq!(agent.model, "Claude Opus 4.7");
3368        assert_eq!(
3369            state.intent.as_deref(),
3370            Some("hook-driven capture test"),
3371            "intent should be pulled from payload message",
3372        );
3373    }
3374
3375    #[test]
3376    fn relay_claude_stop_is_idempotent_when_clean() {
3377        let (temp, repo) = init_repo();
3378        let repo_root = repo.root().to_path_buf();
3379        std::fs::write(repo_root.join("seed.txt"), b"hello").unwrap();
3380        let seed = repo.snapshot(Some("seed".into()), None).unwrap();
3381        drop(repo);
3382
3383        let fresh_repo = Repository::open(temp.path()).unwrap();
3384        let mut runtime = HarnessBridgeRuntime::new(fresh_repo, UserConfig::default());
3385        let payload = serde_json::json!({
3386            "session_id": "claude-sess-clean",
3387            "model": {"id": "claude-sonnet-4-6"},
3388        });
3389        relay_claude(&mut runtime, "Stop", &payload).unwrap();
3390        drop(runtime);
3391
3392        let verify = Repository::open(temp.path()).unwrap();
3393        let head_id = verify.head().unwrap().expect("HEAD preserved");
3394        assert_eq!(
3395            head_id, seed.change_id,
3396            "no change expected when worktree is clean",
3397        );
3398    }
3399
3400    #[test]
3401    fn relay_claude_pre_tool_use_ignores_non_file_tool() {
3402        let (temp, repo) = init_repo();
3403        drop(repo);
3404        let fresh_repo = Repository::open(temp.path()).unwrap();
3405        let mut runtime = HarnessBridgeRuntime::new(fresh_repo, UserConfig::default());
3406        let payload = serde_json::json!({
3407            "session_id": "claude-sess-bash",
3408            "tool_name": "Bash",
3409            "tool_input": {"command": "ls"},
3410        });
3411        // Should succeed without writing any stdout or erroring.
3412        relay_claude(&mut runtime, "PreToolUse", &payload).unwrap();
3413    }
3414
3415    #[test]
3416    fn relay_claude_subagent_start_creates_child_entry_with_parent_key() {
3417        let (temp, repo) = init_repo();
3418        drop(repo);
3419        let fresh_repo = Repository::open(temp.path()).unwrap();
3420        let mut runtime = HarnessBridgeRuntime::new(fresh_repo, UserConfig::default());
3421        let payload = serde_json::json!({
3422            "session_id": "parent-claude-sess",
3423            "agent_id": "child-subagent-xyz",
3424            "model": {"id": "claude-sonnet-4-6"},
3425        });
3426        relay_claude(&mut runtime, "SubagentStart", &payload).unwrap();
3427        drop(runtime);
3428
3429        let verify = Repository::open(temp.path()).unwrap();
3430        let registry = AgentRegistry::new(verify.heddle_dir());
3431        let child = registry
3432            .find_active_by_native_actor_key("claude-code:agent:child-subagent-xyz")
3433            .unwrap()
3434            .expect("subagent AgentEntry should exist after SubagentStart");
3435        assert_eq!(
3436            child.native_parent_actor_key.as_deref(),
3437            Some("claude-code:session:parent-claude-sess"),
3438            "subagent must carry parent session linkage",
3439        );
3440        assert_eq!(child.status, AgentStatus::Active);
3441    }
3442
3443    #[test]
3444    fn relay_claude_subagent_stop_marks_child_entry_complete() {
3445        let (temp, repo) = init_repo();
3446        let repo_root = repo.root().to_path_buf();
3447        drop(repo);
3448
3449        // Start: create the child entry.
3450        let fresh = Repository::open(temp.path()).unwrap();
3451        let mut runtime = HarnessBridgeRuntime::new(fresh, UserConfig::default());
3452        let start_payload = serde_json::json!({
3453            "session_id": "parent-sess",
3454            "agent_id": "worker-1",
3455            "model": {"id": "claude-sonnet-4-6"},
3456        });
3457        relay_claude(&mut runtime, "SubagentStart", &start_payload).unwrap();
3458        drop(runtime);
3459
3460        // Dirty the worktree so SubagentStop also captures a state.
3461        std::fs::write(
3462            repo_root.join("child-output.txt"),
3463            b"subagent produced this",
3464        )
3465        .unwrap();
3466
3467        let fresh = Repository::open(temp.path()).unwrap();
3468        let mut runtime = HarnessBridgeRuntime::new(fresh, UserConfig::default());
3469        let stop_payload = serde_json::json!({
3470            "session_id": "parent-sess",
3471            "agent_id": "worker-1",
3472            "model": {
3473                "id": "claude-sonnet-4-6",
3474                "display_name": "Claude Sonnet 4.6",
3475            },
3476        });
3477        relay_claude(&mut runtime, "SubagentStop", &stop_payload).unwrap();
3478        drop(runtime);
3479
3480        let verify = Repository::open(temp.path()).unwrap();
3481        let registry = AgentRegistry::new(verify.heddle_dir());
3482        let child = registry
3483            .list()
3484            .unwrap()
3485            .into_iter()
3486            .find(|e| e.native_actor_key.as_deref() == Some("claude-code:agent:worker-1"))
3487            .expect("child entry should still exist");
3488        assert_eq!(
3489            child.status,
3490            AgentStatus::Complete,
3491            "SubagentStop should mark the child entry Complete",
3492        );
3493    }
3494
3495    #[test]
3496    fn relay_claude_user_prompt_submit_rotates_segment() {
3497        let (temp, repo) = init_repo();
3498        drop(repo);
3499
3500        let fresh = Repository::open(temp.path()).unwrap();
3501        let mut runtime = HarnessBridgeRuntime::new(fresh, UserConfig::default());
3502        // SessionStart establishes the Heddle session + initial segment.
3503        let session_payload = serde_json::json!({
3504            "session_id": "claude-prompt-sess",
3505            "model": {"id": "claude-opus-4-7", "display_name": "Claude Opus 4.7"},
3506        });
3507        relay_claude(&mut runtime, "SessionStart", &session_payload).unwrap();
3508        let sessions_before = SessionManager::new(runtime.repo.root())
3509            .list_sessions(true)
3510            .unwrap();
3511        let initial_segments = sessions_before
3512            .iter()
3513            .find(|s| !s.segments.is_empty())
3514            .map(|s| s.segments.len())
3515            .unwrap_or(0);
3516
3517        // UserPromptSubmit should force a new segment.
3518        let prompt_payload = serde_json::json!({
3519            "session_id": "claude-prompt-sess",
3520            "model": {"id": "claude-opus-4-7", "display_name": "Claude Opus 4.7"},
3521            "prompt": "write a new feature",
3522        });
3523        relay_claude(&mut runtime, "UserPromptSubmit", &prompt_payload).unwrap();
3524        drop(runtime);
3525
3526        let verify = Repository::open(temp.path()).unwrap();
3527        let sessions_after = SessionManager::new(verify.root())
3528            .list_sessions(true)
3529            .unwrap();
3530        let rotated = sessions_after
3531            .iter()
3532            .any(|s| s.segments.len() > initial_segments);
3533        assert!(
3534            rotated,
3535            "UserPromptSubmit must add at least one segment beyond the SessionStart baseline \
3536             (initial={initial_segments}, sessions_after={:?})",
3537            sessions_after
3538                .iter()
3539                .map(|s| (s.id.clone(), s.segments.len()))
3540                .collect::<Vec<_>>(),
3541        );
3542    }
3543}