1use 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 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(¶ms.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 ®istry,
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 ®istry,
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(¶ms, &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(¶ms.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(¶ms.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(¤t), 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(¤t), 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 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 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 auto: true,
994 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(¶ms.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(¶ms.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(¶ms.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(®istry, &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 ¤t.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 std::fs::write(repo_root.join("seed.txt"), b"hello").unwrap();
3335 let _ = repo.snapshot(Some("seed".into()), None).unwrap();
3336
3337 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 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 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 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 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 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}