@team-agent/installer 0.5.52 → 0.5.54

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 (87) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +111 -3
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  7. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  8. package/crates/team-agent/src/cli/send.rs +1 -0
  9. package/crates/team-agent/src/cli/spec.rs +4 -1
  10. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +12 -0
  15. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  16. package/crates/team-agent/src/db/message_store.rs +31 -2
  17. package/crates/team-agent/src/db/migration.rs +7 -6
  18. package/crates/team-agent/src/db/schema.rs +18 -5
  19. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  20. package/crates/team-agent/src/kill_audit.rs +67 -0
  21. package/crates/team-agent/src/leader/lease.rs +324 -57
  22. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  24. package/crates/team-agent/src/leader/start.rs +144 -23
  25. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  26. package/crates/team-agent/src/leader/types.rs +6 -0
  27. package/crates/team-agent/src/lib.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  30. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  31. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  32. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  33. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  34. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  35. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  36. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  37. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  42. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  43. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  44. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  46. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  47. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  48. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  51. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  52. package/crates/team-agent/src/mcp_server/tools.rs +89 -4
  53. package/crates/team-agent/src/mcp_server/types.rs +7 -0
  54. package/crates/team-agent/src/mcp_server/wire.rs +65 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  56. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  57. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  58. package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
  59. package/crates/team-agent/src/messaging/mod.rs +2 -1
  60. package/crates/team-agent/src/messaging/persist.rs +68 -2
  61. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  62. package/crates/team-agent/src/messaging/results.rs +130 -3
  63. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  64. package/crates/team-agent/src/messaging/send.rs +54 -2
  65. package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
  66. package/crates/team-agent/src/messaging/types.rs +2 -0
  67. package/crates/team-agent/src/messaging/watchers.rs +1 -0
  68. package/crates/team-agent/src/provider/adapter.rs +54 -13
  69. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  70. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  71. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  72. package/crates/team-agent/src/provider/session/capture.rs +395 -52
  73. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  74. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  75. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  76. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  77. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  78. package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
  79. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  80. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  81. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  82. package/crates/team-agent/src/provider/tests.rs +1 -0
  83. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  84. package/crates/team-agent/src/tmux_backend.rs +29 -2
  85. package/package.json +4 -4
  86. package/schemas/result-envelope.schema.json +10 -0
  87. package/skills/team-agent/SKILL.md +11 -3
@@ -1,37 +1,47 @@
1
- use super::{CaptureSessionContext, CapturedSessionCandidate};
1
+ pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
2
+ chrono::DateTime::parse_from_rfc3339(raw)
3
+ .ok()
4
+ .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
5
+ }
2
6
 
3
- pub(super) fn apply_spawned_at_filter(
4
- out: &mut Vec<CapturedSessionCandidate>,
5
- context: &CaptureSessionContext,
7
+ pub(super) fn rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
8
+ created_at_from_rollout_uuid(path)
9
+ .or_else(|| created_at_from_rollout_head(path))
10
+ .or_else(|| created_at_from_rollout_filename(path))
11
+ }
12
+
13
+ pub(super) fn retain_spawn_cwd(
14
+ context: &super::CaptureSessionContext,
15
+ out: &mut Vec<super::CapturedSessionCandidate>,
6
16
  ) {
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
17
  out.retain(|candidate| {
13
- let path = match candidate.captured.rollout_path.as_ref() {
14
- Some(p) => p.as_path(),
15
- None => return false,
18
+ let Some(path) = candidate.captured.rollout_path.as_ref() else {
19
+ return false;
16
20
  };
17
- match codex_rollout_created_at(path) {
18
- Some(created_at) => created_at >= cutoff,
19
- None => std::fs::metadata(path)
20
- .and_then(|meta| meta.modified())
21
- .map(|mtime| mtime >= cutoff)
22
- .unwrap_or(false),
23
- }
21
+ let Ok(text) =
22
+ super::common::read_head_text(path.as_path(), super::common::CAPTURE_HEAD_BYTES)
23
+ else {
24
+ return false;
25
+ };
26
+ super::common::parse_session_records(&text)
27
+ .iter()
28
+ .filter_map(super::common::record_cwd)
29
+ .any(|cwd| {
30
+ super::common::paths_equivalent(std::path::Path::new(&cwd), &context.spawn_cwd)
31
+ })
24
32
  });
25
33
  }
26
34
 
27
- pub(super) fn parse_spawned_at(raw: &str) -> Option<std::time::SystemTime> {
28
- chrono::DateTime::parse_from_rfc3339(raw)
29
- .ok()
30
- .map(|dt| std::time::SystemTime::from(dt.with_timezone(&chrono::Utc)))
31
- }
32
-
33
- fn codex_rollout_created_at(path: &std::path::Path) -> Option<std::time::SystemTime> {
34
- created_at_from_rollout_head(path).or_else(|| created_at_from_rollout_filename(path))
35
+ fn created_at_from_rollout_uuid(path: &std::path::Path) -> Option<std::time::SystemTime> {
36
+ let name = path.file_name()?.to_str()?;
37
+ let stem = name.strip_suffix(".jsonl")?;
38
+ let uuid = stem.get(stem.len().checked_sub(36)?..)?;
39
+ if uuid.as_bytes().get(14).copied() != Some(b'7') {
40
+ return None;
41
+ }
42
+ let millis =
43
+ u64::from_str_radix(&format!("{}{}", uuid.get(..8)?, uuid.get(9..13)?), 16).ok()?;
44
+ std::time::SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(millis))
35
45
  }
36
46
 
37
47
  fn created_at_from_rollout_head(path: &std::path::Path) -> Option<std::time::SystemTime> {
@@ -77,6 +87,10 @@ fn created_at_from_rollout_filename(path: &std::path::Path) -> Option<std::time:
77
87
  #[cfg(test)]
78
88
  mod tests {
79
89
  use super::*;
90
+ use crate::provider::session_scan::{common, CaptureSessionContext, CapturedSessionCandidate};
91
+ use crate::provider::Provider;
92
+ use crate::provider::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
93
+ use std::path::PathBuf;
80
94
 
81
95
  #[test]
82
96
  fn parse_spawned_at_rfc3339_roundtrips_and_rejects_junk() {
@@ -84,4 +98,107 @@ mod tests {
84
98
  assert!(parse_spawned_at("not-a-date").is_none());
85
99
  assert!(parse_spawned_at("").is_none());
86
100
  }
101
+
102
+ fn candidate(path: PathBuf, session_id: &str) -> CapturedSessionCandidate {
103
+ CapturedSessionCandidate {
104
+ captured: CapturedSession {
105
+ session_id: Some(SessionId::new(session_id)),
106
+ rollout_path: Some(RolloutPath::new(path)),
107
+ captured_via: CaptureVia::FsWatch,
108
+ attribution_confidence: Confidence::High,
109
+ spawn_cwd: PathBuf::from("/tmp"),
110
+ },
111
+ embedded_agent_id: None,
112
+ positive_agent_id_match: false,
113
+ agent_path_match: false,
114
+ }
115
+ }
116
+
117
+ #[test]
118
+ fn spawned_at_filter_rejects_prior_round_and_accepts_current_rollout() {
119
+ let dir =
120
+ std::env::temp_dir().join(format!("ta-codex-spawn-filter-{}", std::process::id()));
121
+ let _ = std::fs::remove_dir_all(&dir);
122
+ std::fs::create_dir_all(&dir).unwrap();
123
+ let stale =
124
+ dir.join("rollout-2026-07-21T22-45-40-11111111-1111-4111-8111-111111111111.jsonl");
125
+ let fresh =
126
+ dir.join("rollout-2026-07-21T23-20-01-22222222-2222-4222-8222-222222222222.jsonl");
127
+ std::fs::write(&stale, "{}\n").unwrap();
128
+ std::fs::write(&fresh, "{}\n").unwrap();
129
+ let context = CaptureSessionContext {
130
+ agent_id: "worker".to_string(),
131
+ spawn_cwd: dir.clone(),
132
+ pane_id: None,
133
+ pane_pid: None,
134
+ spawned_at: Some("2026-07-21T23:20:00+00:00".to_string()),
135
+ expected_session_id: None,
136
+ provider_projects_root: None,
137
+ };
138
+ let mut out = vec![
139
+ candidate(stale, "11111111-1111-4111-8111-111111111111"),
140
+ candidate(fresh.clone(), "22222222-2222-4222-8222-222222222222"),
141
+ ];
142
+ common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
143
+ assert_eq!(out.len(), 1);
144
+ assert_eq!(
145
+ out[0].captured.rollout_path.as_ref().unwrap().as_path(),
146
+ fresh
147
+ );
148
+ let _ = std::fs::remove_dir_all(&dir);
149
+ }
150
+
151
+ #[test]
152
+ fn spawned_at_filter_fails_closed_with_an_invalid_boundary() {
153
+ let mut out = vec![candidate(PathBuf::from("/tmp/old.jsonl"), "old")];
154
+ let mut context = CaptureSessionContext {
155
+ agent_id: "worker".to_string(),
156
+ spawn_cwd: PathBuf::from("/tmp"),
157
+ pane_id: None,
158
+ pane_pid: None,
159
+ spawned_at: None,
160
+ expected_session_id: None,
161
+ provider_projects_root: None,
162
+ };
163
+ common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
164
+ assert_eq!(out.len(), 1, "legacy direct scan has no cohort boundary");
165
+
166
+ context.spawned_at = Some("not-a-date".to_string());
167
+ common::apply_spawned_at_filter(Provider::Codex, &context, &mut out);
168
+ assert!(out.is_empty());
169
+ }
170
+
171
+ #[test]
172
+ fn unique_window_uses_created_at_not_active_sibling_mtime() {
173
+ let dir =
174
+ std::env::temp_dir().join(format!("ta-codex-created-window-{}", std::process::id()));
175
+ let _ = std::fs::remove_dir_all(&dir);
176
+ std::fs::create_dir_all(&dir).unwrap();
177
+ let stale =
178
+ dir.join("rollout-2026-07-22T03-12-13-11111111-1111-4111-8111-111111111111.jsonl");
179
+ let fresh =
180
+ dir.join("rollout-2026-07-22T03-15-30-22222222-2222-4222-8222-222222222222.jsonl");
181
+ std::fs::write(&stale, "{}\n").unwrap();
182
+ std::fs::write(&fresh, "{}\n").unwrap();
183
+ let context = CaptureSessionContext {
184
+ agent_id: "clone".to_string(),
185
+ spawn_cwd: dir.clone(),
186
+ pane_id: None,
187
+ pane_pid: None,
188
+ spawned_at: Some("2026-07-22T03:15:29+00:00".to_string()),
189
+ expected_session_id: None,
190
+ provider_projects_root: None,
191
+ };
192
+ let mut out = vec![
193
+ candidate(stale, "11111111-1111-4111-8111-111111111111"),
194
+ candidate(fresh.clone(), "22222222-2222-4222-8222-222222222222"),
195
+ ];
196
+ common::apply_spawn_time_window_if_unique(Provider::Codex, &context, &mut out);
197
+ assert_eq!(out.len(), 1);
198
+ assert_eq!(
199
+ out[0].captured.rollout_path.as_ref().unwrap().as_path(),
200
+ fresh
201
+ );
202
+ let _ = std::fs::remove_dir_all(&dir);
203
+ }
87
204
  }
@@ -0,0 +1,112 @@
1
+ use super::*;
2
+
3
+ fn temp_dir(name: &str) -> PathBuf {
4
+ static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
5
+ let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6
+ let dir =
7
+ std::env::temp_dir().join(format!("ta-session-scan-{name}-{}-{n}", std::process::id()));
8
+ std::fs::create_dir_all(&dir).expect("create temp dir");
9
+ dir
10
+ }
11
+
12
+ fn write_codex_rollout(path: &Path, cwd: &Path, session_id: &str, embedded_agent_id: &str) {
13
+ let text = format!(
14
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
15
+ {{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
16
+ {{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
17
+ cwd.to_string_lossy()
18
+ );
19
+ std::fs::write(path, text).expect("write codex rollout");
20
+ }
21
+
22
+ #[test]
23
+ fn codex_prompt_worker_identity_is_a_positive_match() {
24
+ let dir = temp_dir("positive");
25
+ let rollout = dir.join("rollout-frontend.jsonl");
26
+ write_codex_rollout(&rollout, &dir, "sess-frontend", "frontend");
27
+ let context = CaptureSessionContext {
28
+ agent_id: "frontend".to_string(),
29
+ spawn_cwd: dir.clone(),
30
+ pane_id: None,
31
+ pane_pid: None,
32
+ spawned_at: None,
33
+ expected_session_id: None,
34
+ provider_projects_root: None,
35
+ };
36
+ let candidates = parse_candidate_files(
37
+ Provider::Codex,
38
+ &context,
39
+ vec![SessionCandidate {
40
+ path: rollout.clone(),
41
+ requires_cwd_match: false,
42
+ }],
43
+ );
44
+ assert_eq!(candidates.len(), 1);
45
+ assert!(candidates[0].positive_agent_id_match);
46
+ let _ = std::fs::remove_dir_all(&dir);
47
+ }
48
+
49
+ #[test]
50
+ fn codex_identity_in_incomplete_final_head_record_is_positive() {
51
+ let dir = temp_dir("cross-cap-positive");
52
+ let rollout = dir.join("rollout-worker-a.jsonl");
53
+ let meta = format!(
54
+ "{{\"session_meta\":{{\"payload\":{{\"id\":\"sess-worker-a\",\"cwd\":\"{}\"}}}}}}\n",
55
+ dir.to_string_lossy()
56
+ );
57
+ let marker = "You are Team Agent worker `worker-a` with role `fixture`.";
58
+ let body = format!("{meta}{{\"text\":\"{marker}{}\"}}\n", "x".repeat(70_000));
59
+ std::fs::write(&rollout, body).expect("write cross-cap rollout");
60
+ assert!(!read_head_text(&rollout, CAPTURE_HEAD_BYTES)
61
+ .expect("read complete records")
62
+ .contains(marker));
63
+ let context = CaptureSessionContext {
64
+ agent_id: "worker-a".to_string(),
65
+ spawn_cwd: dir.clone(),
66
+ pane_id: None,
67
+ pane_pid: None,
68
+ spawned_at: None,
69
+ expected_session_id: None,
70
+ provider_projects_root: None,
71
+ };
72
+
73
+ let candidates = parse_candidate_files(
74
+ Provider::Codex,
75
+ &context,
76
+ vec![SessionCandidate {
77
+ path: rollout,
78
+ requires_cwd_match: false,
79
+ }],
80
+ );
81
+
82
+ assert_eq!(candidates.len(), 1);
83
+ assert_eq!(candidates[0].embedded_agent_id.as_deref(), Some("worker-a"));
84
+ assert!(candidates[0].positive_agent_id_match);
85
+ let _ = std::fs::remove_dir_all(&dir);
86
+ }
87
+
88
+ #[test]
89
+ fn codex_prompt_worker_identity_mismatch_is_rejected_for_that_agent() {
90
+ let dir = temp_dir("mismatch");
91
+ let rollout = dir.join("rollout-ios-dev.jsonl");
92
+ write_codex_rollout(&rollout, &dir, "sess-ios-dev", "ios-dev");
93
+ let context = CaptureSessionContext {
94
+ agent_id: "frontend".to_string(),
95
+ spawn_cwd: dir.clone(),
96
+ pane_id: None,
97
+ pane_pid: None,
98
+ spawned_at: None,
99
+ expected_session_id: None,
100
+ provider_projects_root: None,
101
+ };
102
+ let candidates = parse_candidate_files(
103
+ Provider::Codex,
104
+ &context,
105
+ vec![SessionCandidate {
106
+ path: rollout,
107
+ requires_cwd_match: false,
108
+ }],
109
+ );
110
+ assert!(candidates.is_empty());
111
+ let _ = std::fs::remove_dir_all(&dir);
112
+ }
@@ -69,9 +69,11 @@ pub(super) fn parse_candidate_files(
69
69
  let mut out = Vec::new();
70
70
  for candidate in candidates {
71
71
  let path = candidate.path;
72
- let Ok(text) = read_head_text(&path, CAPTURE_HEAD_BYTES) else {
72
+ let Ok(head_bytes) = read_head_bytes(&path, CAPTURE_HEAD_BYTES) else {
73
73
  continue;
74
74
  };
75
+ let text = complete_head_text(&head_bytes);
76
+ let identity_text = String::from_utf8_lossy(&head_bytes);
75
77
  let records = parse_session_records(&text);
76
78
  if records.is_empty() {
77
79
  continue;
@@ -98,7 +100,7 @@ pub(super) fn parse_candidate_files(
98
100
  } else {
99
101
  Confidence::Low
100
102
  };
101
- let embedded_agent_id = embedded_team_agent_worker_id_from_text(&text);
103
+ let embedded_agent_id = embedded_team_agent_worker_id_from_text(&identity_text);
102
104
  if embedded_agent_id
103
105
  .as_deref()
104
106
  .is_some_and(|id| id != context.agent_id.as_str())
@@ -129,7 +131,41 @@ pub(super) fn parse_candidate_files(
129
131
  out
130
132
  }
131
133
 
134
+ /// Reject provider files older than the persisted process-cohort boundary.
135
+ pub(super) fn apply_spawned_at_filter(
136
+ provider: Provider,
137
+ context: &CaptureSessionContext,
138
+ out: &mut Vec<CapturedSessionCandidate>,
139
+ ) {
140
+ let Some(raw_spawned_at) = context.spawned_at.as_deref() else {
141
+ // Legacy direct capture_session_id callers have no cohort boundary;
142
+ // the canonical runtime capture path rejects such rows before scan.
143
+ return;
144
+ };
145
+ let Some(spawned_at) = super::codex::parse_spawned_at(raw_spawned_at) else {
146
+ out.clear();
147
+ return;
148
+ };
149
+ out.retain(|candidate| {
150
+ let Some(path) = candidate.captured.rollout_path.as_ref() else {
151
+ return false;
152
+ };
153
+ if matches!(provider, Provider::Codex) {
154
+ return super::codex::rollout_created_at(path.as_path())
155
+ .is_some_and(|created_at| created_at >= spawned_at);
156
+ }
157
+ candidate_mtime(path.as_path()).is_some_and(|mtime| mtime >= spawned_at)
158
+ });
159
+ }
160
+
161
+ fn candidate_mtime(path: &Path) -> Option<std::time::SystemTime> {
162
+ std::fs::metadata(path)
163
+ .and_then(|meta| meta.modified())
164
+ .ok()
165
+ }
166
+
132
167
  pub(super) fn apply_spawn_time_window_if_unique(
168
+ provider: Provider,
133
169
  context: &CaptureSessionContext,
134
170
  out: &mut Vec<CapturedSessionCandidate>,
135
171
  ) {
@@ -150,12 +186,11 @@ pub(super) fn apply_spawn_time_window_if_unique(
150
186
  .captured
151
187
  .rollout_path
152
188
  .as_ref()
153
- .and_then(|p| {
154
- std::fs::metadata(p.as_path())
155
- .and_then(|m| m.modified())
156
- .ok()
189
+ .and_then(|p| match provider {
190
+ Provider::Codex => super::codex::rollout_created_at(p.as_path()),
191
+ _ => candidate_mtime(p.as_path()),
157
192
  })
158
- .is_some_and(|mtime| mtime >= spawned_at)
193
+ .is_some_and(|created_at| created_at >= spawned_at)
159
194
  })
160
195
  .cloned()
161
196
  .collect();
@@ -208,15 +243,23 @@ fn cap_candidates_by_mtime(out: &mut Vec<SessionCandidate>, cap: usize) {
208
243
  }
209
244
 
210
245
  pub(super) fn read_head_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
246
+ read_head_bytes(path, max_bytes).map(|bytes| complete_head_text(&bytes))
247
+ }
248
+
249
+ fn read_head_bytes(path: &Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
211
250
  use std::io::Read;
212
251
  let file = std::fs::File::open(path)?;
213
252
  let mut bytes = Vec::new();
214
253
  file.take(max_bytes).read_to_end(&mut bytes)?;
254
+ Ok(bytes)
255
+ }
256
+
257
+ fn complete_head_text(bytes: &[u8]) -> String {
215
258
  let complete = match bytes.iter().rposition(|byte| *byte == b'\n') {
216
259
  Some(last_newline) => &bytes[..=last_newline],
217
260
  None => &bytes[..],
218
261
  };
219
- Ok(String::from_utf8_lossy(complete).into_owned())
262
+ String::from_utf8_lossy(complete).into_owned()
220
263
  }
221
264
 
222
265
  fn collect_optional_candidate_files(
@@ -366,7 +409,7 @@ pub(super) fn record_cwd(record: &serde_json::Value) -> Option<String> {
366
409
  .map(ToString::to_string)
367
410
  }
368
411
 
369
- fn paths_equivalent(left: &Path, right: &Path) -> bool {
412
+ pub(super) fn paths_equivalent(left: &Path, right: &Path) -> bool {
370
413
  if left == right {
371
414
  return true;
372
415
  }
@@ -381,86 +424,4 @@ fn path_is_under_team_runtime(path: &Path) -> bool {
381
424
  }
382
425
 
383
426
  #[cfg(test)]
384
- mod tests {
385
- use super::*;
386
-
387
- fn temp_dir(name: &str) -> PathBuf {
388
- static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
389
- let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
390
- let dir =
391
- std::env::temp_dir().join(format!("ta-session-scan-{name}-{}-{n}", std::process::id()));
392
- std::fs::create_dir_all(&dir).expect("create temp dir");
393
- dir
394
- }
395
-
396
- fn write_codex_rollout(path: &Path, cwd: &Path, session_id: &str, embedded_agent_id: &str) {
397
- let text = format!(
398
- "{{\"session_meta\":{{\"payload\":{{\"id\":\"{session_id}\",\"cwd\":\"{}\"}}}}}}\n\
399
- {{\"type\":\"turn_context\",\"payload\":{{}}}}\n\
400
- {{\"type\":\"response_item\",\"payload\":{{\"content\":[{{\"type\":\"input_text\",\"text\":\"You are Team Agent worker `{embedded_agent_id}` with role `fixture`.\"}}]}}}}\n",
401
- cwd.to_string_lossy()
402
- );
403
- std::fs::write(path, text).expect("write codex rollout");
404
- }
405
-
406
- #[test]
407
- fn codex_prompt_worker_identity_is_a_positive_match() {
408
- let dir = temp_dir("positive");
409
- let rollout = dir.join("rollout-frontend.jsonl");
410
- write_codex_rollout(&rollout, &dir, "sess-frontend", "frontend");
411
- let context = CaptureSessionContext {
412
- agent_id: "frontend".to_string(),
413
- spawn_cwd: dir.clone(),
414
- pane_id: None,
415
- pane_pid: None,
416
- spawned_at: None,
417
- expected_session_id: None,
418
- provider_projects_root: None,
419
- };
420
- let candidates = parse_candidate_files(
421
- Provider::Codex,
422
- &context,
423
- vec![SessionCandidate {
424
- path: rollout.clone(),
425
- requires_cwd_match: false,
426
- }],
427
- );
428
- assert_eq!(candidates.len(), 1);
429
- assert!(
430
- candidates[0].positive_agent_id_match,
431
- "Codex transcript prompt identity must be treated as a positive worker id source"
432
- );
433
- let _ = std::fs::remove_file(&rollout);
434
- let _ = std::fs::remove_dir_all(&dir);
435
- }
436
-
437
- #[test]
438
- fn codex_prompt_worker_identity_mismatch_is_rejected_for_that_agent() {
439
- let dir = temp_dir("mismatch");
440
- let rollout = dir.join("rollout-ios-dev.jsonl");
441
- write_codex_rollout(&rollout, &dir, "sess-ios-dev", "ios-dev");
442
- let context = CaptureSessionContext {
443
- agent_id: "frontend".to_string(),
444
- spawn_cwd: dir.clone(),
445
- pane_id: None,
446
- pane_pid: None,
447
- spawned_at: None,
448
- expected_session_id: None,
449
- provider_projects_root: None,
450
- };
451
- let candidates = parse_candidate_files(
452
- Provider::Codex,
453
- &context,
454
- vec![SessionCandidate {
455
- path: rollout.clone(),
456
- requires_cwd_match: false,
457
- }],
458
- );
459
- assert!(
460
- candidates.is_empty(),
461
- "state agent=frontend must not accept a Codex rollout whose prompt says ios-dev"
462
- );
463
- let _ = std::fs::remove_file(&rollout);
464
- let _ = std::fs::remove_dir_all(&dir);
465
- }
466
- }
427
+ mod tests;
@@ -1,14 +1,14 @@
1
- use std::path::{Path, PathBuf};
1
+ use std::path::Path;
2
2
 
3
3
  use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
4
4
 
5
5
  use super::{CaptureSessionContext, CapturedSessionCandidate};
6
6
 
7
7
  pub(super) fn scan_session_store(context: &CaptureSessionContext) -> Vec<CapturedSessionCandidate> {
8
- let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
8
+ let Ok(home) = crate::provider::adapters::copilot_fork::copilot_home() else {
9
9
  return Vec::new();
10
10
  };
11
- let db_path = home.join(".copilot").join("session-store.db");
11
+ let db_path = home.join("session-store.db");
12
12
  if !db_path.exists() {
13
13
  return Vec::new();
14
14
  }
@@ -59,6 +59,7 @@ mod tests {
59
59
  #![allow(clippy::unwrap_used)]
60
60
 
61
61
  use super::*;
62
+ use std::path::PathBuf;
62
63
  use std::sync::atomic::{AtomicU64, Ordering};
63
64
 
64
65
  struct HomeGuard {
@@ -175,6 +176,49 @@ mod tests {
175
176
  let _ = std::fs::remove_dir_all(&base);
176
177
  }
177
178
 
179
+ #[test]
180
+ #[serial_test::serial(env)]
181
+ fn copilot_scanner_respects_copilot_home() {
182
+ let base = tmp_root("custom-home");
183
+ let copilot_home = base.join("isolated-copilot");
184
+ std::fs::create_dir_all(&copilot_home).unwrap();
185
+ let worker_id = "1142c4c2-0000-4000-8000-000000000001";
186
+ let conn = rusqlite::Connection::open(copilot_home.join("session-store.db")).unwrap();
187
+ conn.execute(
188
+ "create table sessions (id text primary key, cwd text, updated_at integer)",
189
+ [],
190
+ )
191
+ .unwrap();
192
+ conn.execute(
193
+ "insert into sessions (id, cwd, updated_at) values (?1, '/tmp/ws', 1)",
194
+ [worker_id],
195
+ )
196
+ .unwrap();
197
+ drop(conn);
198
+ let previous = std::env::var_os("COPILOT_HOME");
199
+ std::env::set_var("COPILOT_HOME", &copilot_home);
200
+ let ctx = CaptureSessionContext {
201
+ agent_id: "worker".to_string(),
202
+ spawn_cwd: base.join("ws"),
203
+ pane_id: None,
204
+ pane_pid: None,
205
+ spawned_at: None,
206
+ expected_session_id: Some(SessionId::new(worker_id)),
207
+ provider_projects_root: None,
208
+ };
209
+ let out = scan_session_store(&ctx);
210
+ assert_eq!(out.len(), 1);
211
+ assert_eq!(
212
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
213
+ worker_id
214
+ );
215
+ match previous {
216
+ Some(value) => std::env::set_var("COPILOT_HOME", value),
217
+ None => std::env::remove_var("COPILOT_HOME"),
218
+ }
219
+ let _ = std::fs::remove_dir_all(&base);
220
+ }
221
+
178
222
  #[test]
179
223
  #[serial_test::serial(env)]
180
224
  fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
@@ -64,16 +64,16 @@ pub(crate) fn scan_session_candidates_once(
64
64
 
65
65
  let candidates = common::candidate_session_files(provider, context)?;
66
66
  let mut out = common::parse_candidate_files(provider, context, candidates);
67
-
68
67
  if matches!(provider, Provider::Codex) {
69
- codex::apply_spawned_at_filter(&mut out, context);
68
+ codex::retain_spawn_cwd(context, &mut out);
70
69
  }
70
+ common::apply_spawned_at_filter(provider, context, &mut out);
71
71
 
72
72
  if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
73
73
  return claude::apply_expected_session_filter(context, out);
74
74
  }
75
75
 
76
- common::apply_spawn_time_window_if_unique(context, &mut out);
76
+ common::apply_spawn_time_window_if_unique(provider, context, &mut out);
77
77
  common::sort_expected_first_if_needed(context, &mut out);
78
78
  Ok(out)
79
79
  }