@team-agent/installer 0.4.11 → 0.5.1

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 (80) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +98 -5
  6. package/crates/team-agent/src/cli/mod.rs +2 -0
  7. package/crates/team-agent/src/cli/named_address.rs +864 -0
  8. package/crates/team-agent/src/cli/send.rs +104 -0
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
  12. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  13. package/crates/team-agent/src/cli/types.rs +4 -0
  14. package/crates/team-agent/src/compiler.rs +11 -20
  15. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  16. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  17. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  19. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  20. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  21. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  22. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  23. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  24. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  25. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  26. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  27. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  28. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  29. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  34. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  35. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  38. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  41. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
  42. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  43. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  44. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  45. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  46. package/crates/team-agent/src/messaging/activity.rs +43 -18
  47. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  48. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  49. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  50. package/crates/team-agent/src/messaging/results.rs +34 -9
  51. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  52. package/crates/team-agent/src/model/spec.rs +10 -6
  53. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  54. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  55. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  56. package/crates/team-agent/src/provider/classify.rs +23 -48
  57. package/crates/team-agent/src/provider/command.rs +16 -7
  58. package/crates/team-agent/src/provider/faults.rs +93 -53
  59. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  60. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  61. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  62. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  63. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  64. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  65. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  66. package/crates/team-agent/src/provider/types.rs +33 -1
  67. package/crates/team-agent/src/provider/wire.rs +171 -1
  68. package/crates/team-agent/src/state/identity.rs +242 -57
  69. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  70. package/crates/team-agent/src/state/mod.rs +5 -1
  71. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  72. package/crates/team-agent/src/state/ownership.rs +12 -11
  73. package/crates/team-agent/src/state/paths.rs +13 -6
  74. package/crates/team-agent/src/state/persist.rs +671 -128
  75. package/crates/team-agent/src/state/projection.rs +240 -49
  76. package/crates/team-agent/src/state/selector.rs +11 -5
  77. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  78. package/crates/team-agent/src/tmux_backend.rs +42 -5
  79. package/crates/team-agent/src/transport/test_support.rs +55 -6
  80. package/package.json +4 -4
@@ -0,0 +1,309 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use crate::provider::types::SessionId;
4
+ use crate::provider::Provider;
5
+
6
+ use super::{CaptureSessionContext, CapturedSessionCandidate};
7
+
8
+ pub(super) fn projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<PathBuf> {
9
+ let canonical = std::fs::canonicalize(spawn_cwd).unwrap_or_else(|_| spawn_cwd.to_path_buf());
10
+ let encoded = encode_projects_dir(&canonical.to_string_lossy());
11
+ if encoded.is_empty() {
12
+ return None;
13
+ }
14
+ Some(home.join(".claude").join("projects").join(encoded))
15
+ }
16
+
17
+ fn encode_projects_dir(path: &str) -> String {
18
+ let mut out = String::with_capacity(path.len());
19
+ for c in path.chars() {
20
+ if c.is_ascii_alphanumeric() {
21
+ out.push(c);
22
+ } else {
23
+ out.push('-');
24
+ }
25
+ }
26
+ out
27
+ }
28
+
29
+ pub(crate) fn rollout_path_has_leader_marker(provider: Provider, rollout_path: &Path) -> bool {
30
+ if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
31
+ return false;
32
+ }
33
+ let Ok(text) = super::common::read_head_text(rollout_path, super::common::CAPTURE_HEAD_BYTES)
34
+ else {
35
+ return false;
36
+ };
37
+ let records = super::common::parse_session_records(&text);
38
+ records_have_leader_marker(&records)
39
+ }
40
+
41
+ pub(super) fn records_have_leader_marker(records: &[serde_json::Value]) -> bool {
42
+ records.iter().any(|record| {
43
+ let custom_title = record
44
+ .get("customTitle")
45
+ .and_then(serde_json::Value::as_str)
46
+ .map(str::to_ascii_lowercase);
47
+ let agent_name = record
48
+ .get("agentName")
49
+ .and_then(serde_json::Value::as_str)
50
+ .map(str::to_ascii_lowercase);
51
+ matches!(custom_title.as_deref(), Some("claude leader"))
52
+ || matches!(agent_name.as_deref(), Some("claude leader"))
53
+ })
54
+ }
55
+
56
+ pub(super) fn has_cwd_field(record: &serde_json::Value) -> bool {
57
+ super::common::record_cwd(record).is_some()
58
+ }
59
+
60
+ pub(super) fn apply_expected_session_filter(
61
+ context: &CaptureSessionContext,
62
+ mut out: Vec<CapturedSessionCandidate>,
63
+ ) -> Result<Vec<CapturedSessionCandidate>, crate::provider::types::ProviderError> {
64
+ if let Some(expected) = context.expected_session_id.as_ref() {
65
+ if let Some(hit) = out
66
+ .iter()
67
+ .find(|candidate| session_matches(candidate, expected))
68
+ {
69
+ return Ok(vec![hit.clone()]);
70
+ }
71
+ let positive_only: Vec<CapturedSessionCandidate> = out
72
+ .iter()
73
+ .filter(|candidate| candidate.positive_agent_id_match || candidate.agent_path_match)
74
+ .cloned()
75
+ .collect();
76
+ return Ok(positive_only);
77
+ }
78
+ super::common::apply_spawn_time_window_if_unique(context, &mut out);
79
+ Ok(out)
80
+ }
81
+
82
+ fn session_matches(candidate: &CapturedSessionCandidate, expected: &SessionId) -> bool {
83
+ candidate
84
+ .captured
85
+ .session_id
86
+ .as_ref()
87
+ .is_some_and(|session| session.as_str() == expected.as_str())
88
+ }
89
+
90
+ #[cfg(test)]
91
+ mod tests {
92
+ #![allow(clippy::unwrap_used)]
93
+
94
+ use super::*;
95
+ use crate::provider::types::SessionId;
96
+ use crate::provider::Provider;
97
+ use std::sync::atomic::{AtomicU64, Ordering};
98
+
99
+ fn tmp_root(tag: &str) -> PathBuf {
100
+ static CTR: AtomicU64 = AtomicU64::new(0);
101
+ let dir = std::env::temp_dir().join(format!(
102
+ "ta-e6-attr-{}-{}-{}",
103
+ tag,
104
+ std::process::id(),
105
+ CTR.fetch_add(1, Ordering::Relaxed)
106
+ ));
107
+ std::fs::create_dir_all(&dir).unwrap();
108
+ dir
109
+ }
110
+
111
+ fn write_transcript(dir: &Path, uuid: &str, cwd: &Path) -> PathBuf {
112
+ std::fs::create_dir_all(dir).unwrap();
113
+ let path = dir.join(format!("{uuid}.jsonl"));
114
+ let line = serde_json::json!({
115
+ "sessionId": uuid,
116
+ "cwd": cwd.to_string_lossy(),
117
+ });
118
+ std::fs::write(&path, format!("{line}\n")).unwrap();
119
+ path
120
+ }
121
+
122
+ #[test]
123
+ fn claude_leader_marker_in_custom_title_is_detected() {
124
+ let records = vec![serde_json::json!({
125
+ "type": "custom-title",
126
+ "customTitle": "claude leader",
127
+ "sessionId": "ea059b82",
128
+ })];
129
+ assert!(records_have_leader_marker(&records));
130
+ }
131
+
132
+ #[test]
133
+ fn claude_leader_marker_in_agent_name_is_detected() {
134
+ let records = vec![serde_json::json!({
135
+ "type": "agent-name",
136
+ "agentName": "claude leader",
137
+ "sessionId": "ea059b82",
138
+ })];
139
+ assert!(records_have_leader_marker(&records));
140
+ }
141
+
142
+ #[test]
143
+ fn claude_worker_records_have_no_leader_marker() {
144
+ let records = vec![
145
+ serde_json::json!({
146
+ "type": "custom-title",
147
+ "customTitle": "claude release-engineer",
148
+ "sessionId": "abc12345",
149
+ }),
150
+ serde_json::json!({
151
+ "type": "user",
152
+ "content": "Team Agent message from leader: do X",
153
+ "sessionId": "abc12345",
154
+ }),
155
+ ];
156
+ assert!(!records_have_leader_marker(&records));
157
+ }
158
+
159
+ #[test]
160
+ fn claude_leader_marker_is_case_insensitive() {
161
+ let records = vec![serde_json::json!({
162
+ "customTitle": "Claude Leader",
163
+ })];
164
+ assert!(records_have_leader_marker(&records));
165
+ }
166
+
167
+ #[test]
168
+ fn claude_projects_dir_for_cwd_encodes_slashes_to_dashes() {
169
+ let home = Path::new("/home/u");
170
+ let cwd = tmp_root("encode");
171
+ let got = projects_dir_for_cwd(home, &cwd).unwrap();
172
+ let canon = std::fs::canonicalize(&cwd).unwrap();
173
+ let expected_leaf = encode_projects_dir(&canon.to_string_lossy());
174
+ assert_eq!(
175
+ got,
176
+ home.join(".claude").join("projects").join(expected_leaf)
177
+ );
178
+ let _ = std::fs::remove_dir_all(&cwd);
179
+ }
180
+
181
+ #[test]
182
+ fn encode_claude_projects_dir_parity_with_real_claude_naming() {
183
+ assert_eq!(
184
+ encode_projects_dir("/Users/alauda/code"),
185
+ "-Users-alauda-code"
186
+ );
187
+ assert_eq!(
188
+ encode_projects_dir("/Users/alauda/Documents/code/agent前沿探索/多agent协作"),
189
+ "-Users-alauda-Documents-code-agent------agent--"
190
+ );
191
+ assert_eq!(
192
+ encode_projects_dir("/Users/foo bar.baz/v1.2"),
193
+ "-Users-foo-bar-baz-v1-2"
194
+ );
195
+ assert_eq!(
196
+ encode_projects_dir("/proj/.team/runtime"),
197
+ "-proj--team-runtime"
198
+ );
199
+ }
200
+
201
+ #[test]
202
+ fn scan_expected_session_id_hit_returns_only_that_candidate() {
203
+ let base = tmp_root("c-hit");
204
+ let cwd = base.join("ws");
205
+ std::fs::create_dir_all(&cwd).unwrap();
206
+ let proj = base.join("projects");
207
+ write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
208
+ write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
209
+ let ctx = CaptureSessionContext {
210
+ agent_id: "w1".to_string(),
211
+ spawn_cwd: cwd.clone(),
212
+ pane_id: None,
213
+ pane_pid: None,
214
+ spawned_at: None,
215
+ expected_session_id: Some(SessionId::new("22222222-2222-4222-8222-222222222222")),
216
+ provider_projects_root: Some(proj.clone()),
217
+ };
218
+ let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
219
+ assert_eq!(out.len(), 1);
220
+ assert_eq!(
221
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
222
+ "22222222-2222-4222-8222-222222222222"
223
+ );
224
+ let _ = std::fs::remove_dir_all(&base);
225
+ }
226
+
227
+ #[test]
228
+ fn scan_spawn_time_window_disambiguates_two_siblings() {
229
+ let base = tmp_root("b-window");
230
+ let cwd = base.join("ws");
231
+ std::fs::create_dir_all(&cwd).unwrap();
232
+ let proj = base.join("projects");
233
+ let old = write_transcript(&proj, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &cwd);
234
+ let new = write_transcript(&proj, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", &cwd);
235
+ let long_ago =
236
+ std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
237
+ filetime_set(&old, long_ago);
238
+ let ctx = CaptureSessionContext {
239
+ agent_id: "w1".to_string(),
240
+ spawn_cwd: cwd.clone(),
241
+ pane_id: None,
242
+ pane_pid: None,
243
+ spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
244
+ expected_session_id: None,
245
+ provider_projects_root: Some(proj.clone()),
246
+ };
247
+ let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
248
+ assert_eq!(out.len(), 1);
249
+ assert_eq!(
250
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
251
+ "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
252
+ );
253
+ let _ = new;
254
+ let _ = std::fs::remove_dir_all(&base);
255
+ }
256
+
257
+ #[test]
258
+ fn scan_no_spawned_at_keeps_both_siblings_ambiguous() {
259
+ let base = tmp_root("b-noamb");
260
+ let cwd = base.join("ws");
261
+ std::fs::create_dir_all(&cwd).unwrap();
262
+ let proj = base.join("projects");
263
+ write_transcript(&proj, "cccccccc-cccc-4ccc-8ccc-cccccccccccc", &cwd);
264
+ write_transcript(&proj, "dddddddd-dddd-4ddd-8ddd-dddddddddddd", &cwd);
265
+ let ctx = CaptureSessionContext {
266
+ agent_id: "w1".to_string(),
267
+ spawn_cwd: cwd.clone(),
268
+ pane_id: None,
269
+ pane_pid: None,
270
+ spawned_at: None,
271
+ expected_session_id: None,
272
+ provider_projects_root: Some(proj.clone()),
273
+ };
274
+ let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
275
+ assert!(out.len() >= 2);
276
+ let _ = std::fs::remove_dir_all(&base);
277
+ }
278
+
279
+ #[test]
280
+ fn scan_expected_session_id_miss_refuses_to_pick_leader_sibling() {
281
+ let base = tmp_root("strict-no-leader-fallback");
282
+ let cwd = base.join("ws");
283
+ std::fs::create_dir_all(&cwd).unwrap();
284
+ let proj = base.join("projects");
285
+ let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
286
+ let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
287
+ let _ = (leader, stale);
288
+ let ctx = CaptureSessionContext {
289
+ agent_id: "claude-worker".to_string(),
290
+ spawn_cwd: cwd.clone(),
291
+ pane_id: None,
292
+ pane_pid: None,
293
+ spawned_at: Some("2020-01-01T00:00:00+00:00".to_string()),
294
+ expected_session_id: Some(SessionId::new("99999999-9999-4999-8999-999999999999")),
295
+ provider_projects_root: Some(proj.clone()),
296
+ };
297
+ let out = super::super::scan_session_candidates_once(Provider::ClaudeCode, &ctx).unwrap();
298
+ assert!(
299
+ out.is_empty(),
300
+ "expected-id miss must not fall back to latest"
301
+ );
302
+ let _ = std::fs::remove_dir_all(&base);
303
+ }
304
+
305
+ fn filetime_set(path: &Path, when: std::time::SystemTime) {
306
+ let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
307
+ f.set_modified(when).unwrap();
308
+ }
309
+ }
@@ -0,0 +1,40 @@
1
+ use super::{CaptureSessionContext, CapturedSessionCandidate};
2
+
3
+ pub(super) fn apply_spawned_at_filter(
4
+ out: &mut Vec<CapturedSessionCandidate>,
5
+ context: &CaptureSessionContext,
6
+ ) {
7
+ let Some(spawned_at) = context.spawned_at.as_deref().and_then(parse_spawned_at) else {
8
+ return;
9
+ };
10
+ let grace = std::time::Duration::from_secs(5);
11
+ let cutoff = spawned_at.checked_sub(grace).unwrap_or(spawned_at);
12
+ out.retain(|candidate| {
13
+ let path = match candidate.captured.rollout_path.as_ref() {
14
+ Some(p) => p.as_path(),
15
+ None => return false,
16
+ };
17
+ std::fs::metadata(path)
18
+ .and_then(|meta| meta.modified())
19
+ .map(|mtime| mtime >= cutoff)
20
+ .unwrap_or(false)
21
+ });
22
+ }
23
+
24
+ pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
25
+ chrono::DateTime::parse_from_rfc3339(raw)
26
+ .ok()
27
+ .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
28
+ }
29
+
30
+ #[cfg(test)]
31
+ mod tests {
32
+ use super::*;
33
+
34
+ #[test]
35
+ fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
36
+ assert!(parse_spawned_at("2026-06-10T21:40:00+00:00").is_some());
37
+ assert!(parse_spawned_at("not-a-date").is_none());
38
+ assert!(parse_spawned_at("").is_none());
39
+ }
40
+ }
@@ -0,0 +1,350 @@
1
+ use std::path::{Path, PathBuf};
2
+
3
+ use crate::provider::helpers::{find_session_id, parse_jsonl_records};
4
+ use crate::provider::types::{
5
+ CaptureVia, CapturedSession, Confidence, ProviderError, RolloutPath, SessionId,
6
+ };
7
+ use crate::provider::Provider;
8
+
9
+ use super::{CaptureSessionContext, CapturedSessionCandidate};
10
+
11
+ pub(super) struct SessionCandidate {
12
+ pub(super) path: PathBuf,
13
+ requires_cwd_match: bool,
14
+ }
15
+
16
+ /// P2 (C-P2-2/3) / Python claude.py:300 — candidates are capped to the newest `cap`
17
+ /// by mtime (descending priority: old candidates must not crowd out new ones).
18
+ const CAPTURE_CANDIDATE_CAP: usize = 300;
19
+
20
+ /// P2 (C-P2-1): head window >= Python's 200-line read.
21
+ pub(super) const CAPTURE_HEAD_BYTES: u64 = 65_536;
22
+
23
+ pub(super) fn candidate_session_files(
24
+ provider: Provider,
25
+ context: &CaptureSessionContext,
26
+ ) -> Result<Vec<SessionCandidate>, ProviderError> {
27
+ let mut out = Vec::new();
28
+ if let Some(root) = context.provider_projects_root.as_ref() {
29
+ collect_optional_candidate_files(root, &context.agent_id, &mut out)?;
30
+ }
31
+ collect_candidate_files(&context.spawn_cwd, &context.agent_id, 0, false, &mut out)?;
32
+ if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
33
+ match provider {
34
+ Provider::Codex => {
35
+ collect_optional_candidate_files(
36
+ &home.join(".codex").join("sessions"),
37
+ &context.agent_id,
38
+ &mut out,
39
+ )?;
40
+ }
41
+ Provider::Claude | Provider::ClaudeCode => {
42
+ if let Some(dir) = super::claude::projects_dir_for_cwd(&home, &context.spawn_cwd) {
43
+ collect_optional_candidate_files(&dir, &context.agent_id, &mut out)?;
44
+ }
45
+ collect_optional_candidate_files(
46
+ &home.join(".claude").join("projects"),
47
+ &context.agent_id,
48
+ &mut out,
49
+ )?;
50
+ }
51
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => {}
52
+ }
53
+ }
54
+ out.sort_by(|a, b| {
55
+ a.requires_cwd_match
56
+ .cmp(&b.requires_cwd_match)
57
+ .then_with(|| a.path.to_string_lossy().cmp(&b.path.to_string_lossy()))
58
+ });
59
+ out.dedup_by(|a, b| a.path == b.path && a.requires_cwd_match == b.requires_cwd_match);
60
+ cap_candidates_by_mtime(&mut out, CAPTURE_CANDIDATE_CAP);
61
+ Ok(out)
62
+ }
63
+
64
+ pub(super) fn parse_candidate_files(
65
+ provider: Provider,
66
+ context: &CaptureSessionContext,
67
+ candidates: Vec<SessionCandidate>,
68
+ ) -> Vec<CapturedSessionCandidate> {
69
+ let mut out = Vec::new();
70
+ for candidate in candidates {
71
+ let path = candidate.path;
72
+ let Ok(text) = read_head_text(&path, CAPTURE_HEAD_BYTES) else {
73
+ continue;
74
+ };
75
+ let records = parse_session_records(&text);
76
+ if records.is_empty() {
77
+ continue;
78
+ }
79
+ if candidate.requires_cwd_match
80
+ && !provider_home_records_match_spawn_cwd(&records, &context.spawn_cwd)
81
+ {
82
+ continue;
83
+ }
84
+ let session_id = records.iter().find_map(find_session_id);
85
+ if matches!(provider, Provider::Claude | Provider::ClaudeCode)
86
+ && session_id.is_some()
87
+ && !records.iter().any(super::claude::has_cwd_field)
88
+ {
89
+ continue;
90
+ }
91
+ let captured_via = if session_id.is_some() {
92
+ CaptureVia::FsWatch
93
+ } else {
94
+ CaptureVia::FsMtimeFallback
95
+ };
96
+ let attribution_confidence = if session_id.is_some() {
97
+ Confidence::High
98
+ } else {
99
+ Confidence::Low
100
+ };
101
+ let positive_agent_id_match = candidate_text_has_team_agent_id(&text, context);
102
+ let agent_path_match = candidate_path_matches_agent_id(&path, context);
103
+ if matches!(provider, Provider::Claude | Provider::ClaudeCode)
104
+ && super::claude::records_have_leader_marker(&records)
105
+ {
106
+ continue;
107
+ }
108
+ out.push(CapturedSessionCandidate {
109
+ captured: CapturedSession {
110
+ session_id: session_id.map(SessionId::new),
111
+ rollout_path: Some(RolloutPath::new(path)),
112
+ captured_via,
113
+ attribution_confidence,
114
+ spawn_cwd: context.spawn_cwd.clone(),
115
+ },
116
+ positive_agent_id_match,
117
+ agent_path_match,
118
+ });
119
+ }
120
+ out
121
+ }
122
+
123
+ pub(super) fn apply_spawn_time_window_if_unique(
124
+ context: &CaptureSessionContext,
125
+ out: &mut Vec<CapturedSessionCandidate>,
126
+ ) {
127
+ if context.expected_session_id.is_some() && out.len() <= 1 {
128
+ return;
129
+ }
130
+ let Some(spawned_at) = context
131
+ .spawned_at
132
+ .as_deref()
133
+ .and_then(super::codex::parse_spawned_at)
134
+ else {
135
+ return;
136
+ };
137
+ let within: Vec<CapturedSessionCandidate> = out
138
+ .iter()
139
+ .filter(|candidate| {
140
+ candidate
141
+ .captured
142
+ .rollout_path
143
+ .as_ref()
144
+ .and_then(|p| {
145
+ std::fs::metadata(p.as_path())
146
+ .and_then(|m| m.modified())
147
+ .ok()
148
+ })
149
+ .is_some_and(|mtime| mtime >= spawned_at)
150
+ })
151
+ .cloned()
152
+ .collect();
153
+ if within.len() == 1 {
154
+ *out = within;
155
+ }
156
+ }
157
+
158
+ pub(super) fn sort_expected_first_if_needed(
159
+ context: &CaptureSessionContext,
160
+ out: &mut [CapturedSessionCandidate],
161
+ ) {
162
+ if let Some(expected) = context.expected_session_id.as_ref() {
163
+ out.sort_by_key(|candidate| {
164
+ candidate
165
+ .captured
166
+ .session_id
167
+ .as_ref()
168
+ .is_none_or(|session| session.as_str() != expected.as_str())
169
+ });
170
+ }
171
+ }
172
+
173
+ fn cap_candidates_by_mtime(out: &mut Vec<SessionCandidate>, cap: usize) {
174
+ if out.len() <= cap {
175
+ return;
176
+ }
177
+ let mut ranked: Vec<(std::time::SystemTime, usize)> = out
178
+ .iter()
179
+ .enumerate()
180
+ .map(|(index, candidate)| {
181
+ let mtime = std::fs::metadata(&candidate.path)
182
+ .and_then(|meta| meta.modified())
183
+ .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
184
+ (mtime, index)
185
+ })
186
+ .collect();
187
+ ranked.sort_by(|a, b| b.0.cmp(&a.0));
188
+ let keep: std::collections::BTreeSet<usize> = ranked
189
+ .into_iter()
190
+ .take(cap)
191
+ .map(|(_, index)| index)
192
+ .collect();
193
+ let mut index = 0;
194
+ out.retain(|_| {
195
+ let kept = keep.contains(&index);
196
+ index += 1;
197
+ kept
198
+ });
199
+ }
200
+
201
+ pub(super) fn read_head_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
202
+ use std::io::Read;
203
+ let file = std::fs::File::open(path)?;
204
+ let mut bytes = Vec::new();
205
+ file.take(max_bytes).read_to_end(&mut bytes)?;
206
+ let complete = match bytes.iter().rposition(|byte| *byte == b'\n') {
207
+ Some(last_newline) => &bytes[..=last_newline],
208
+ None => &bytes[..],
209
+ };
210
+ Ok(String::from_utf8_lossy(complete).into_owned())
211
+ }
212
+
213
+ fn collect_optional_candidate_files(
214
+ dir: &Path,
215
+ agent_id: &str,
216
+ out: &mut Vec<SessionCandidate>,
217
+ ) -> Result<(), ProviderError> {
218
+ if dir.exists() {
219
+ let _ = collect_candidate_files(dir, agent_id, 0, true, out);
220
+ }
221
+ Ok(())
222
+ }
223
+
224
+ fn collect_candidate_files(
225
+ dir: &Path,
226
+ agent_id: &str,
227
+ depth: usize,
228
+ requires_cwd_match: bool,
229
+ out: &mut Vec<SessionCandidate>,
230
+ ) -> Result<(), ProviderError> {
231
+ if depth > 4 {
232
+ return Ok(());
233
+ }
234
+ let entries = match std::fs::read_dir(dir) {
235
+ Ok(entries) => entries,
236
+ Err(e) if depth == 0 => return Err(ProviderError::Io(format!("{}: {e}", dir.display()))),
237
+ Err(_) => return Ok(()),
238
+ };
239
+ for entry in entries {
240
+ let Ok(entry) = entry else {
241
+ continue;
242
+ };
243
+ let path = entry.path();
244
+ if path.is_dir() {
245
+ collect_candidate_files(
246
+ &path,
247
+ agent_id,
248
+ depth.saturating_add(1),
249
+ requires_cwd_match,
250
+ out,
251
+ )?;
252
+ } else if looks_like_session_file(&path, agent_id) {
253
+ out.push(SessionCandidate {
254
+ path,
255
+ requires_cwd_match,
256
+ });
257
+ }
258
+ }
259
+ Ok(())
260
+ }
261
+
262
+ fn looks_like_session_file(path: &Path, agent_id: &str) -> bool {
263
+ if path_is_under_team_runtime(path) {
264
+ return false;
265
+ }
266
+ let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
267
+ return false;
268
+ };
269
+ name.ends_with(".jsonl")
270
+ || name.ends_with(".json")
271
+ || name.contains("session")
272
+ || name.contains("rollout")
273
+ || (!agent_id.is_empty() && name.contains(agent_id))
274
+ }
275
+
276
+ pub(super) fn parse_session_records(text: &str) -> Vec<serde_json::Value> {
277
+ match serde_json::from_str::<serde_json::Value>(text) {
278
+ Ok(serde_json::Value::Array(items)) => items,
279
+ Ok(value) => vec![value],
280
+ Err(_) => parse_jsonl_records(text),
281
+ }
282
+ }
283
+
284
+ fn provider_home_records_match_spawn_cwd(records: &[serde_json::Value], spawn_cwd: &Path) -> bool {
285
+ let cwd_values: Vec<String> = records.iter().filter_map(record_cwd).collect();
286
+ !cwd_values.is_empty()
287
+ && cwd_values
288
+ .iter()
289
+ .any(|cwd| paths_equivalent(Path::new(cwd), spawn_cwd))
290
+ }
291
+
292
+ fn candidate_text_has_team_agent_id(text: &str, context: &CaptureSessionContext) -> bool {
293
+ let id = context.agent_id.as_str();
294
+ if id.is_empty() {
295
+ return false;
296
+ }
297
+ [
298
+ format!("\"TEAM_AGENT_ID\":\"{id}\""),
299
+ format!("\"TEAM_AGENT_ID\": \"{id}\""),
300
+ format!("TEAM_AGENT_ID={id}"),
301
+ format!("env.TEAM_AGENT_ID=\"{id}\""),
302
+ format!("env.TEAM_AGENT_ID=\\\"{id}\\\""),
303
+ format!("\"TEAM_AGENT_AGENT_ID\":\"{id}\""),
304
+ format!("\"TEAM_AGENT_AGENT_ID\": \"{id}\""),
305
+ format!("TEAM_AGENT_AGENT_ID={id}"),
306
+ ]
307
+ .iter()
308
+ .any(|needle| text.contains(needle))
309
+ }
310
+
311
+ fn candidate_path_matches_agent_id(path: &Path, context: &CaptureSessionContext) -> bool {
312
+ let id = context.agent_id.as_str();
313
+ if id.is_empty() {
314
+ return false;
315
+ }
316
+ let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
317
+ return false;
318
+ };
319
+ let dashed = id.replace('_', "-");
320
+ name.contains(id) || name.contains(&dashed)
321
+ }
322
+
323
+ pub(super) fn record_cwd(record: &serde_json::Value) -> Option<String> {
324
+ record
325
+ .get("cwd")
326
+ .and_then(serde_json::Value::as_str)
327
+ .or_else(|| {
328
+ record
329
+ .get("session_meta")
330
+ .and_then(|v| v.get("payload"))
331
+ .or_else(|| record.get("payload"))
332
+ .and_then(|v| v.get("cwd"))
333
+ .and_then(serde_json::Value::as_str)
334
+ })
335
+ .map(ToString::to_string)
336
+ }
337
+
338
+ fn paths_equivalent(left: &Path, right: &Path) -> bool {
339
+ if left == right {
340
+ return true;
341
+ }
342
+ let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
343
+ let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
344
+ left == right || left.parent().is_some_and(|parent| parent == right)
345
+ }
346
+
347
+ fn path_is_under_team_runtime(path: &Path) -> bool {
348
+ path.components()
349
+ .any(|c| c.as_os_str() == std::ffi::OsStr::new(".team"))
350
+ }