@team-agent/installer 0.4.6 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +33 -1
  4. package/crates/team-agent/src/cli/status_port.rs +119 -66
  5. package/crates/team-agent/src/cli/tests/status_send.rs +74 -48
  6. package/crates/team-agent/src/coordinator/tick.rs +2 -21
  7. package/crates/team-agent/src/leader/helpers.rs +1 -22
  8. package/crates/team-agent/src/lifecycle/launch.rs +1 -11
  9. package/crates/team-agent/src/lifecycle/profile_launch.rs +25 -11
  10. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -1
  11. package/crates/team-agent/src/lifecycle/restart/common.rs +20 -26
  12. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +30 -8
  13. package/crates/team-agent/src/lifecycle/restart/selection.rs +67 -16
  14. package/crates/team-agent/src/lifecycle/restart.rs +1 -0
  15. package/crates/team-agent/src/lifecycle/tests/core.rs +99 -0
  16. package/crates/team-agent/src/messaging/delivery.rs +113 -0
  17. package/crates/team-agent/src/provider/adapter.rs +18 -372
  18. package/crates/team-agent/src/provider/adapters/claude.rs +106 -0
  19. package/crates/team-agent/src/provider/adapters/codex.rs +117 -0
  20. package/crates/team-agent/src/provider/adapters/copilot.rs +124 -0
  21. package/crates/team-agent/src/provider/adapters/fake.rs +17 -0
  22. package/crates/team-agent/src/provider/adapters/mod.rs +27 -0
  23. package/crates/team-agent/src/provider/mod.rs +6 -0
  24. package/crates/team-agent/src/provider/session/capture.rs +131 -1
  25. package/crates/team-agent/src/provider/wire.rs +139 -0
  26. package/crates/team-agent/src/state/paths.rs +8 -4
  27. package/crates/team-agent/src/state/persist.rs +17 -0
  28. package/package.json +4 -4
  29. package/skills/team-agent/SKILL.md +6 -0
@@ -208,7 +208,7 @@ pub(crate) fn start_agent_at_paths(
208
208
  None,
209
209
  Some(resolved_team_key.as_str()),
210
210
  )?;
211
- mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety)?;
211
+ mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, transport, &safety, start_mode)?;
212
212
  // **0.3.24 add-agent socket drift fix**: keep `state.tmux_endpoint` /
213
213
  // `state.tmux_socket` synchronized with the transport actually used for the
214
214
  // spawn. Without this, add-agent / fork-agent could spawn to a socket that
@@ -700,6 +700,7 @@ fn mark_agent_started(
700
700
  spawn: &SpawnedAgentWindow,
701
701
  transport: &dyn crate::transport::Transport,
702
702
  safety: &DangerousApproval,
703
+ start_mode: StartMode,
703
704
  ) -> Result<(), LifecycleError> {
704
705
  let Some(agent) = state
705
706
  .get_mut("agents")
@@ -712,6 +713,31 @@ fn mark_agent_started(
712
713
  agent_id
713
714
  )));
714
715
  };
716
+ // S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): on a Fresh /
717
+ // FreshAfterMissingRollout start, the prior session's authoritative
718
+ // capture tuple MUST be cleared before persist_command_plan_state
719
+ // writes the new _pending_session_id. Otherwise old session_id +
720
+ // rollout_path coexist with new _pending_session_id and
721
+ // agent_session_complete returns true on the stale tuple — capture
722
+ // never re-binds to the new process, and any delivered token lands
723
+ // in the old transcript (the leader/unassigned mis-attribution seen
724
+ // in the gate evidence). This applies to all providers that resume:
725
+ // codex, claude, copilot. Reset_agent --discard-session already does
726
+ // this at common.rs:1144-1188; here we mirror it for start-agent /
727
+ // restart-agent fresh paths so the fresh-tuple invariant is global.
728
+ if matches!(start_mode, StartMode::Fresh | StartMode::FreshAfterMissingRollout) {
729
+ for field in [
730
+ "session_id",
731
+ "rollout_path",
732
+ "captured_at",
733
+ "captured_via",
734
+ "attribution_confidence",
735
+ "capture_state",
736
+ "attribution_ambiguous",
737
+ ] {
738
+ agent.remove(field);
739
+ }
740
+ }
715
741
  agent.insert("status".to_string(), serde_json::json!("running"));
716
742
  agent.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
717
743
  agent.insert("window".to_string(), serde_json::json!(window));
@@ -895,10 +895,25 @@ pub(crate) fn restart_required_missing_session_agent_ids(state: &serde_json::Val
895
895
  .get("status")
896
896
  .and_then(|value| value.as_str())
897
897
  .is_some_and(|status| status == "running");
898
- // E6 层2 (C2): required-missing 谓词只看 session_id 有无 + 是否在跑。
899
- // pane 绑定 / first_send_at gate 时刻天然可空(自启动 worker leader 从未发消息),
900
- // 不能作判据 —— 否则真丢上下文的 null-session worker 被漏判,走静默 fresh。
901
- missing_session_id && is_running
898
+ // E6 层2 (C2) + RESTART-RESUME-001 (0.4.8): required-missing
899
+ // predicate gates on session_id absence + running, but ALSO
900
+ // skips never-captured workers (no session_id AND no context
901
+ // signals at all). A never-captured worker has nothing to
902
+ // lose by fresh-start, so it must not block convergence and
903
+ // burn the capture deadline. This matches the selection-stage
904
+ // partial-resume semantic in
905
+ // selection.rs::classify_resume_decision (never_captured →
906
+ // FreshStart without --allow-fresh).
907
+ //
908
+ // The "has context to preserve" signal is the shared
909
+ // restart_agent_has_context_to_preserve helper: first_send_at
910
+ // (leader→worker delivery), last_result_at (MCP report path
911
+ // that may skip first_send_at), or task_prompt_delivered.
912
+ // Only context-bearing null-session workers continue to
913
+ // require convergence (so we never silently drop context).
914
+ missing_session_id
915
+ && is_running
916
+ && !super::selection::restart_agent_never_captured(agent, None)
902
917
  })
903
918
  .collect::<Vec<_>>();
904
919
  missing.sort();
@@ -913,28 +928,7 @@ pub(super) fn agent_window(agent: &serde_json::Value, agent_id: &AgentId) -> Str
913
928
  .to_string()
914
929
  }
915
930
 
916
- pub(super) fn parse_provider(raw: &str) -> Option<Provider> {
917
- match raw {
918
- "claude" => Some(Provider::Claude),
919
- "claude_code" => Some(Provider::ClaudeCode),
920
- "codex" => Some(Provider::Codex),
921
- "copilot" => Some(Provider::Copilot),
922
- "gemini_cli" => Some(Provider::GeminiCli),
923
- "fake" => Some(Provider::Fake),
924
- _ => None,
925
- }
926
- }
927
-
928
- pub(super) fn provider_wire(provider: Provider) -> &'static str {
929
- match provider {
930
- Provider::Claude => "claude",
931
- Provider::ClaudeCode => "claude_code",
932
- Provider::Codex => "codex",
933
- Provider::Copilot => "copilot",
934
- Provider::GeminiCli => "gemini_cli",
935
- Provider::Fake => "fake",
936
- }
937
- }
931
+ pub(super) use crate::provider::wire::{parse_provider, provider_wire};
938
932
 
939
933
  pub(super) fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
940
934
  match raw {
@@ -1183,13 +1183,17 @@ fn try_autobind_leader_after_restart(
1183
1183
  .pointer("/leader_receiver/provider")
1184
1184
  .and_then(serde_json::Value::as_str)
1185
1185
  })
1186
- .and_then(|s| match s {
1187
- "codex" => Some(crate::model::enums::Provider::Codex),
1188
- "claude" | "claude_code" | "claude-code" => {
1189
- Some(crate::model::enums::Provider::ClaudeCode)
1190
- }
1191
- "copilot" => Some(crate::model::enums::Provider::Copilot),
1192
- _ => None,
1186
+ .and_then(|s| {
1187
+ // Legacy auto-attach: collapse any claude variant to ClaudeCode
1188
+ // (this site historically treated `Claude` and `ClaudeCode` as
1189
+ // the same attach target). Wire-format `parse_provider` keeps
1190
+ // them distinct everywhere else.
1191
+ crate::provider::wire::parse_provider(s).map(|p| match p {
1192
+ crate::model::enums::Provider::Claude => {
1193
+ crate::model::enums::Provider::ClaudeCode
1194
+ }
1195
+ other => other,
1196
+ })
1193
1197
  })
1194
1198
  .unwrap_or(crate::model::enums::Provider::ClaudeCode);
1195
1199
  let team_str = team;
@@ -1357,11 +1361,29 @@ fn mark_agent_respawned(
1357
1361
  // Claude family clear path uniformly. The Copilot scanner
1358
1362
  // (provider/adapter.rs:1217-1228) already gates expected-id with a
1359
1363
  // sqlite point-check, so no truth is lost.
1364
+ // S1-CAPTURE-001 (0.4.8): on Fresh / FreshAfterMissingRollout, clear the
1365
+ // FULL prior-session authoritative capture tuple — not just session_id.
1366
+ // Mirrors mark_agent_started (restart/agent.rs:728-740) so the rebuild path
1367
+ // (multi-agent restart) and the single-agent start path enforce the same
1368
+ // fresh-tuple invariant. Without this, save_restart_state's persist
1369
+ // backfill can revive the stale rollout_path/captured_at/capture_state
1370
+ // tuple from latest, defeating the fresh-tuple guarantee and leaving
1371
+ // delivered tokens in the old transcript (leader/unassigned mis-attrib).
1360
1372
  if matches!(
1361
1373
  restart_mode,
1362
1374
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
1363
1375
  ) {
1364
- agent.insert("session_id".to_string(), serde_json::Value::Null);
1376
+ for field in [
1377
+ "session_id",
1378
+ "rollout_path",
1379
+ "captured_at",
1380
+ "captured_via",
1381
+ "attribution_confidence",
1382
+ "capture_state",
1383
+ "attribution_ambiguous",
1384
+ ] {
1385
+ agent.remove(field);
1386
+ }
1365
1387
  }
1366
1388
  crate::lifecycle::launch::persist_command_plan_state(agent, &spawn.plan, &spawn.profile_launch);
1367
1389
  persist_effective_approval_policy_for_restart(agent, safety);
@@ -53,6 +53,57 @@ pub fn classify_first_send_at(raw: &serde_json::Value) -> FirstSendAtState {
53
53
  }
54
54
  }
55
55
 
56
+ /// RESTART-RESUME-001 (0.4.8): true if the agent has any signal of prior
57
+ /// interaction whose context would be lost by silent fresh-start. Checks
58
+ /// `first_send_at` (leader→worker delivery), `last_result_at` (worker
59
+ /// report_result), and `task_prompt_delivered` (MCP-only worker first task).
60
+ /// Used by both the selection-stage never-captured decision and the
61
+ /// pre-selection convergence missing-set predicate so the two layers share
62
+ /// a single "needs context preservation" semantic.
63
+ ///
64
+ /// CR M2 callers (must stay in sync):
65
+ /// * lifecycle/restart/selection.rs (never_captured branch, classify_resume_decision)
66
+ /// * lifecycle/restart/common.rs::restart_required_missing_session_agent_ids
67
+ pub(crate) fn restart_agent_has_context_to_preserve(agent: &serde_json::Value) -> bool {
68
+ let has_valid_first_send_at = matches!(
69
+ classify_first_send_at(agent.get("first_send_at").unwrap_or(&serde_json::Value::Null)),
70
+ FirstSendAtState::Valid
71
+ );
72
+ if has_valid_first_send_at {
73
+ return true;
74
+ }
75
+ let has_last_result_at = agent
76
+ .get("last_result_at")
77
+ .and_then(serde_json::Value::as_str)
78
+ .is_some_and(|s| !s.is_empty());
79
+ if has_last_result_at {
80
+ return true;
81
+ }
82
+ agent
83
+ .get("task_prompt_delivered")
84
+ .and_then(serde_json::Value::as_bool)
85
+ .unwrap_or(false)
86
+ }
87
+
88
+ /// RESTART-RESUME-001: an agent is "never-captured" when its session_id is
89
+ /// absent AND there is no context to preserve. Such an agent is safe to
90
+ /// auto-fresh without `--allow-fresh` and should NOT be required to capture
91
+ /// a transcript before restart proceeds.
92
+ pub(crate) fn restart_agent_never_captured(
93
+ agent: &serde_json::Value,
94
+ session_id: Option<&str>,
95
+ ) -> bool {
96
+ let session_present = session_id.is_some_and(|s| !s.is_empty())
97
+ || agent
98
+ .get("session_id")
99
+ .and_then(serde_json::Value::as_str)
100
+ .is_some_and(|s| !s.is_empty());
101
+ if session_present {
102
+ return false;
103
+ }
104
+ !restart_agent_has_context_to_preserve(agent)
105
+ }
106
+
56
107
  fn is_python_fromisoformat_like(raw: &str) -> bool {
57
108
  if raw.is_empty() {
58
109
  return false;
@@ -221,25 +272,25 @@ pub(crate) fn classify_restart_plan_with_resume_validation(
221
272
  }
222
273
  _ => (true, Vec::new()),
223
274
  };
224
- // 0.4.7 partial-resume: when a worker has NEVER been captured (no
225
- // session_id AND first_send_at is Absent), it is structurally
226
- // non-resumable — there is no context to lose, so auto-fresh is
227
- // safe even without --allow-fresh. This prevents a single
228
- // never-captured role (e.g. MCP-only worker that the leader never
229
- // messaged) from blocking restart of the other 6 roles that DO
230
- // have complete resume tuples.
275
+ // 0.4.7 partial-resume + RESTART-RESUME-001 (0.4.8): when a worker
276
+ // has NEVER been captured (no session_id AND no context-bearing
277
+ // signal at all), it is structurally non-resumable — there is no
278
+ // context to lose, so auto-fresh is safe even without --allow-fresh.
231
279
  //
232
- // The "never_captured" predicate is the conjunction:
233
- // * session_id is None (no provider session bound), AND
234
- // * first_send_at is Absent (leader has never injected a
235
- // message so we never armed the capture path either).
280
+ // The "never_captured" predicate is now the shared
281
+ // restart_agent_never_captured(): session_id absent AND none of
282
+ // first_send_at(Valid) / last_result_at / task_prompt_delivered.
283
+ // This matches the pre-selection convergence semantic in
284
+ // common.rs::restart_required_missing_session_agent_ids so both
285
+ // layers refuse / auto-fresh in unison.
236
286
  //
237
- // If session_id is None but first_send_at is Valid, that's the
238
- // "received message but session not captured" bug state — keep
239
- // the Refuse so we never silently drop context (architect rule:
240
- // "绝不静默 fresh").
287
+ // If session_id is None but ANY context signal exists, that's the
288
+ // "received message/result but session not captured" bug state —
289
+ // keep the Refuse so we never silently drop context (architect
290
+ // rule: "绝不静默 fresh").
291
+ let _ = first_send_at_state; // retained for the Corrupt branch above
241
292
  let never_captured =
242
- session_id.is_none() && matches!(first_send_at_state, FirstSendAtState::Absent);
293
+ restart_agent_never_captured(agent, session_id.as_ref().map(|s| s.as_str()));
243
294
  let decision = if session_id.is_some() && provider_can_resume && resume_backing_exists {
244
295
  ResumeDecision::Resume
245
296
  } else if session_id.is_some() && allow_fresh {
@@ -35,6 +35,7 @@ mod team_state;
35
35
  pub use agent::{reset_agent, reset_agent_with_transport, start_agent, start_agent_with_transport, stop_agent, stop_agent_with_transport};
36
36
  pub(crate) use agent::start_agent_at_paths;
37
37
  pub(crate) use common::refresh_missing_provider_sessions;
38
+ pub(crate) use common::restart_required_missing_session_agent_ids;
38
39
  // 0.3.24 add-agent socket drift fix: state-aware tmux resolver shared with
39
40
  // `lifecycle::launch::add_agent` / `fork_agent` so all three (restart / add / fork)
40
41
  // route to the SAME tmux socket the live team uses.
@@ -636,6 +636,105 @@ fn classify_restart_plan_never_captured_null_session_auto_fresh_partial_resume()
636
636
  );
637
637
  }
638
638
 
639
+ #[test]
640
+ fn restart_required_missing_skips_never_captured_workers() {
641
+ // RESTART-RESUME-001 (0.4.8): the pre-selection convergence missing-set
642
+ // predicate must skip never-captured workers (null session_id, no
643
+ // first_send_at/last_result_at/task_prompt_delivered) so a single
644
+ // never-captured role doesn't burn the capture deadline and trigger
645
+ // resume_not_ready when the selection stage would auto-fresh it anyway.
646
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
647
+ let state = json!({
648
+ "agents": {
649
+ "w1": { "provider": "claude", "session_id": null, "status": "running" }
650
+ }
651
+ });
652
+ let missing = restart_required_missing_session_agent_ids(&state);
653
+ assert!(
654
+ missing.is_empty(),
655
+ "never-captured null-session worker must NOT appear in required-missing; \
656
+ got {missing:?}"
657
+ );
658
+ }
659
+
660
+ #[test]
661
+ fn restart_required_missing_keeps_null_session_with_first_send_at() {
662
+ // RESTART-RESUME-001: null session_id + valid first_send_at means the
663
+ // leader DID send a message; that context must be preserved → still
664
+ // missing → restart refuses without --allow-fresh.
665
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
666
+ let state = json!({
667
+ "agents": {
668
+ "w1": {
669
+ "provider": "claude",
670
+ "session_id": null,
671
+ "status": "running",
672
+ "spawn_cwd": "/tmp/ws",
673
+ "first_send_at": "2026-01-01T00:00:00+00:00",
674
+ }
675
+ }
676
+ });
677
+ let missing = restart_required_missing_session_agent_ids(&state);
678
+ assert_eq!(
679
+ missing,
680
+ vec!["w1".to_string()],
681
+ "null-session + valid first_send_at (context bearing) must remain in \
682
+ required-missing; got {missing:?}"
683
+ );
684
+ }
685
+
686
+ #[test]
687
+ fn restart_required_missing_keeps_null_session_with_last_result_at() {
688
+ // RESTART-RESUME-001: MCP / report_result paths may not have
689
+ // first_send_at, but a non-empty last_result_at means the worker DID
690
+ // produce a result; that context must be preserved.
691
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
692
+ let state = json!({
693
+ "agents": {
694
+ "w1": {
695
+ "provider": "claude",
696
+ "session_id": null,
697
+ "status": "running",
698
+ "spawn_cwd": "/tmp/ws",
699
+ "last_result_at": "2026-01-01T00:00:00+00:00",
700
+ }
701
+ }
702
+ });
703
+ let missing = restart_required_missing_session_agent_ids(&state);
704
+ assert_eq!(
705
+ missing,
706
+ vec!["w1".to_string()],
707
+ "null-session + last_result_at must remain in required-missing; \
708
+ got {missing:?}"
709
+ );
710
+ }
711
+
712
+ #[test]
713
+ fn classify_restart_plan_null_session_with_last_result_at_refuses_without_allow_fresh() {
714
+ // RESTART-RESUME-001: selection stage must also refuse null-session +
715
+ // last_result_at (no first_send_at) without --allow-fresh. Otherwise the
716
+ // pre-selection convergence and selection-stage refuse semantics drift
717
+ // and the gate fails for MCP-only report paths.
718
+ let state = json!({
719
+ "agents": {
720
+ "w1": {
721
+ "provider": "claude",
722
+ "session_id": null,
723
+ "last_result_at": "2026-01-01T00:00:00+00:00",
724
+ }
725
+ }
726
+ });
727
+ let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
728
+ assert_eq!(plan.decisions.len(), 1);
729
+ assert_eq!(
730
+ plan.decisions[0].decision,
731
+ ResumeDecision::Refuse,
732
+ "null-session + last_result_at must Refuse without --allow-fresh \
733
+ (context to preserve); got {:?}",
734
+ plan.decisions[0].decision
735
+ );
736
+ }
737
+
639
738
  #[test]
640
739
  fn classify_restart_plan_never_interacted_null_session_with_allow_fresh_marks_forced_fresh() {
641
740
  // E6 层2: 同上自启动 null-session worker,但显式 --allow-fresh → 用户主动认账丢上下文 → FreshStart。
@@ -452,6 +452,71 @@ pub fn deliver_pending_message(
452
452
  channel: None,
453
453
  });
454
454
  }
455
+ // S1-CAPTURE-001 (0.4.8, CR M4 Claude phase-1): for Claude/ClaudeCode
456
+ // recipients with a known authoritative rollout_path, verify the message
457
+ // token actually reached the worker's transcript before marking
458
+ // delivered. This catches the gate's mis-attribution: pane inject
459
+ // succeeded but the token landed in the leader/unassigned transcript,
460
+ // not the worker's. Budget per architect plan: 64KB tail / single
461
+ // per-delivery check / short grace window. Phase-1 Claude only —
462
+ // codex/copilot keep the pre-fix behaviour (will be addressed in
463
+ // phase-2 once Claude phase-1 is field-validated).
464
+ if let Some((rollout_path, provider_wire_str)) = claude_recipient_rollout(state, &message.recipient) {
465
+ let token_marker = format!("[team-agent-token:{message_id}]");
466
+ let grace = std::time::Duration::from_millis(200);
467
+ let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
468
+ let mut transcript_has_token = false;
469
+ loop {
470
+ transcript_has_token =
471
+ rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
472
+ if transcript_has_token || std::time::Instant::now() >= deadline {
473
+ break;
474
+ }
475
+ std::thread::sleep(grace);
476
+ }
477
+ if !transcript_has_token {
478
+ // Loud but non-fatal: emit a mismatch event for diagnose/status
479
+ // observability. Do NOT mark delivered — degrade to
480
+ // submitted_unverified so capture is forced to re-attribute.
481
+ let reason = format!(
482
+ "transcript_missing:provider={provider_wire_str},rollout={}",
483
+ rollout_path.display()
484
+ );
485
+ event_log.write(
486
+ "provider.session.transcript_mismatch",
487
+ serde_json::json!({
488
+ "message_id": message_id,
489
+ "recipient": message.recipient,
490
+ "provider": provider_wire_str,
491
+ "rollout_path": rollout_path.to_string_lossy(),
492
+ "pane_id": state
493
+ .get("agents")
494
+ .and_then(|a| a.get(&message.recipient))
495
+ .and_then(|a| a.get("pane_id"))
496
+ .and_then(serde_json::Value::as_str)
497
+ .unwrap_or(""),
498
+ "spawn_epoch": state
499
+ .get("agents")
500
+ .and_then(|a| a.get(&message.recipient))
501
+ .and_then(|a| a.get("spawn_epoch"))
502
+ .and_then(serde_json::Value::as_u64)
503
+ .unwrap_or(0),
504
+ "reason": "transcript_missing",
505
+ }),
506
+ )?;
507
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
508
+ return Ok(DeliveryOutcome {
509
+ ok: false,
510
+ status: DeliveryStatus::Failed,
511
+ message_status: MessageStatusShadow("submitted_unverified".to_string()),
512
+ message_id: Some(message_id.to_string()),
513
+ verification: Some(reason),
514
+ stage: Some(DeliveryStage::Submit),
515
+ reason: None,
516
+ channel: None,
517
+ });
518
+ }
519
+ }
455
520
  store.mark(message_id, "delivered", None)?;
456
521
  event_log.write(
457
522
  "message.delivered",
@@ -1690,3 +1755,51 @@ pub fn retry_injection_after_trust_auto_answer(
1690
1755
  channel: None,
1691
1756
  })
1692
1757
  }
1758
+
1759
+ /// S1-CAPTURE-001 (0.4.8 phase-1): returns (rollout_path, provider_wire) when
1760
+ /// the recipient agent is Claude/ClaudeCode with a known authoritative
1761
+ /// rollout_path; otherwise None (skip transcript verify). Phase-1 Claude only —
1762
+ /// codex/copilot return None and keep pre-fix delivery semantics.
1763
+ fn claude_recipient_rollout(
1764
+ state: &serde_json::Value,
1765
+ recipient: &str,
1766
+ ) -> Option<(std::path::PathBuf, &'static str)> {
1767
+ let agent = state.get("agents")?.get(recipient)?;
1768
+ let provider = agent.get("provider").and_then(serde_json::Value::as_str)?;
1769
+ let provider_wire_str = match provider {
1770
+ "claude" => "claude",
1771
+ "claude_code" | "claude-code" => "claude_code",
1772
+ _ => return None,
1773
+ };
1774
+ let rollout = agent
1775
+ .get("rollout_path")
1776
+ .and_then(serde_json::Value::as_str)
1777
+ .filter(|s| !s.is_empty())?;
1778
+ Some((std::path::PathBuf::from(rollout), provider_wire_str))
1779
+ }
1780
+
1781
+ /// S1-CAPTURE-001 (0.4.8 phase-1): bounded tail read of the rollout file
1782
+ /// searching for `needle`. Reads up to `tail_bytes` from the end of the file
1783
+ /// (default 64KB budget). Returns true iff the needle appears in the tail.
1784
+ /// Silent on read errors — callers treat missing/unreadable as "token not
1785
+ /// present" which forces the unverified path.
1786
+ fn rollout_tail_contains(path: &std::path::Path, needle: &str, tail_bytes: u64) -> bool {
1787
+ use std::io::{Read, Seek, SeekFrom};
1788
+ let Ok(mut file) = std::fs::File::open(path) else {
1789
+ return false;
1790
+ };
1791
+ let Ok(metadata) = file.metadata() else {
1792
+ return false;
1793
+ };
1794
+ let len = metadata.len();
1795
+ let start = len.saturating_sub(tail_bytes);
1796
+ if file.seek(SeekFrom::Start(start)).is_err() {
1797
+ return false;
1798
+ }
1799
+ let mut buf = Vec::with_capacity(tail_bytes.min(len) as usize);
1800
+ if file.take(tail_bytes).read_to_end(&mut buf).is_err() {
1801
+ return false;
1802
+ }
1803
+ let haystack = String::from_utf8_lossy(&buf);
1804
+ haystack.contains(needle)
1805
+ }