@team-agent/installer 0.5.59 → 0.5.61

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 (68) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +44 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +183 -0
  5. package/crates/team-agent/src/cli/emit.rs +63 -14
  6. package/crates/team-agent/src/cli/leader.rs +8 -1
  7. package/crates/team-agent/src/cli/mod.rs +263 -5
  8. package/crates/team-agent/src/cli/named_address.rs +18 -1
  9. package/crates/team-agent/src/cli/send/presentation.rs +2 -0
  10. package/crates/team-agent/src/cli/spec.rs +4 -1
  11. package/crates/team-agent/src/cli/status_port/store.rs +7 -3
  12. package/crates/team-agent/src/cli/tests/named_address.rs +65 -0
  13. package/crates/team-agent/src/cli/types.rs +33 -0
  14. package/crates/team-agent/src/communication_mode/mod.rs +53 -0
  15. package/crates/team-agent/src/compiler/tests.rs +5 -5
  16. package/crates/team-agent/src/compiler.rs +53 -1
  17. package/crates/team-agent/src/coordinator/tick.rs +21 -8
  18. package/crates/team-agent/src/db/message_store.rs +45 -1
  19. package/crates/team-agent/src/fake_worker.rs +21 -1
  20. package/crates/team-agent/src/leader/start.rs +732 -94
  21. package/crates/team-agent/src/leader/tests/identity.rs +64 -57
  22. package/crates/team-agent/src/leader/tests/identity_session_names.rs +42 -0
  23. package/crates/team-agent/src/lib.rs +4 -0
  24. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +58 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +57 -51
  26. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +148 -8
  27. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +5 -0
  28. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch.rs +1 -1
  30. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -1
  32. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +35 -1
  33. package/crates/team-agent/src/lifecycle/types.rs +1 -0
  34. package/crates/team-agent/src/lifecycle/worker_command_context.rs +29 -11
  35. package/crates/team-agent/src/mcp_server/normalize.rs +1 -0
  36. package/crates/team-agent/src/mcp_server/tests/send.rs +26 -0
  37. package/crates/team-agent/src/mcp_server/tests/wire.rs +1 -1
  38. package/crates/team-agent/src/mcp_server/tools.rs +100 -66
  39. package/crates/team-agent/src/mcp_server/types.rs +5 -0
  40. package/crates/team-agent/src/mcp_server/wire.rs +27 -21
  41. package/crates/team-agent/src/messaging/delivery.rs +105 -38
  42. package/crates/team-agent/src/messaging/leader_channel.rs +33 -10
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +40 -23
  44. package/crates/team-agent/src/messaging/presentation.rs +109 -0
  45. package/crates/team-agent/src/messaging/results.rs +165 -17
  46. package/crates/team-agent/src/messaging/send.rs +94 -81
  47. package/crates/team-agent/src/messaging/tests/leader_channel.rs +6 -6
  48. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +187 -0
  49. package/crates/team-agent/src/messaging/tests/runtime.rs +13 -6
  50. package/crates/team-agent/src/messaging/types.rs +5 -0
  51. package/crates/team-agent/src/messaging/watchers.rs +5 -0
  52. package/crates/team-agent/src/model/mod.rs +1 -0
  53. package/crates/team-agent/src/model/pane_authority_refusal.rs +358 -0
  54. package/crates/team-agent/src/model/spec.rs +16 -0
  55. package/crates/team-agent/src/provider/adapter.rs +57 -0
  56. package/crates/team-agent/src/provider/adapters/claude_fork.rs +10 -2
  57. package/crates/team-agent/src/provider/session/capture.rs +69 -34
  58. package/crates/team-agent/src/provider/session/context_fork/codex.rs +386 -9
  59. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +3 -14
  60. package/crates/team-agent/src/provider/session/context_fork.rs +7 -2
  61. package/crates/team-agent/src/provider/session/mod.rs +3 -2
  62. package/crates/team-agent/src/provider/session_scan/claude.rs +92 -3
  63. package/crates/team-agent/src/provider/session_scan/codex.rs +28 -0
  64. package/crates/team-agent/src/provider/session_scan/common.rs +34 -8
  65. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  66. package/crates/team-agent/src/topology.rs +3 -0
  67. package/package.json +4 -4
  68. package/skills/team-agent/command-coverage.json +379 -0
@@ -1,14 +1,319 @@
1
1
  use super::*;
2
+ use std::collections::BTreeSet;
3
+ use std::fs::{File, OpenOptions};
4
+ use std::io::{Read, Write};
5
+
6
+ #[derive(Debug)]
7
+ pub(crate) struct CodexForkMaterialization {
8
+ path: PathBuf,
9
+ keep: bool,
10
+ }
11
+
12
+ impl CodexForkMaterialization {
13
+ pub(crate) fn path(&self) -> &Path {
14
+ &self.path
15
+ }
16
+
17
+ fn mark_handoff(&mut self) {
18
+ self.keep = true;
19
+ }
20
+ }
21
+
22
+ impl crate::provider::adapter::ForkBackingMaterialization for CodexForkMaterialization {
23
+ fn path(&self) -> &Path {
24
+ self.path()
25
+ }
26
+
27
+ fn handoff(&mut self) {
28
+ self.mark_handoff();
29
+ }
30
+ }
31
+
32
+ impl Drop for CodexForkMaterialization {
33
+ fn drop(&mut self) {
34
+ if !self.keep {
35
+ let _ = std::fs::remove_file(&self.path);
36
+ }
37
+ }
38
+ }
39
+
40
+ pub(crate) fn materialize_codex_fork(
41
+ source_path: &Path,
42
+ source_session_id: &SessionId,
43
+ source_agent_id: &str,
44
+ target_agent_id: &str,
45
+ plan: &mut CommandPlan,
46
+ ) -> Result<CodexForkMaterialization, ProviderError> {
47
+ let target_session_id = SessionId::new(codex_session_v7());
48
+ let parent = source_path.parent().ok_or_else(|| {
49
+ ProviderError::Io(format!(
50
+ "codex source backing has no parent: {}",
51
+ source_path.display()
52
+ ))
53
+ })?;
54
+ let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S");
55
+ let target_path = parent.join(format!(
56
+ "rollout-{timestamp}-{}.jsonl",
57
+ target_session_id.as_str()
58
+ ));
59
+ let source = complete_source_snapshot(source_path)?;
60
+ let rewritten = rewrite_snapshot(
61
+ &source,
62
+ source_session_id,
63
+ &target_session_id,
64
+ source_agent_id,
65
+ target_agent_id,
66
+ )?;
67
+ publish_target_no_clobber(&target_path, rewritten.as_bytes())?;
68
+ if let Err(error) =
69
+ validate_materialized_target(&target_path, &target_session_id, target_agent_id)
70
+ {
71
+ let _ = std::fs::remove_file(&target_path);
72
+ return Err(error);
73
+ }
74
+ apply_resume_target(plan, source_session_id, &target_session_id)?;
75
+ Ok(CodexForkMaterialization {
76
+ path: target_path,
77
+ keep: false,
78
+ })
79
+ }
80
+
81
+ fn complete_source_snapshot(path: &Path) -> Result<String, ProviderError> {
82
+ let file = File::open(path).map_err(|error| ProviderError::Io(error.to_string()))?;
83
+ let visible_len = file
84
+ .metadata()
85
+ .map_err(|error| ProviderError::Io(error.to_string()))?
86
+ .len();
87
+ let mut bytes = Vec::new();
88
+ file.take(visible_len)
89
+ .read_to_end(&mut bytes)
90
+ .map_err(|error| ProviderError::Io(error.to_string()))?;
91
+ let Some(boundary) = bytes.iter().rposition(|byte| *byte == b'\n') else {
92
+ return Err(ProviderError::Io(
93
+ "codex fork source has no complete JSONL record".to_string(),
94
+ ));
95
+ };
96
+ bytes.truncate(boundary + 1);
97
+ String::from_utf8(bytes).map_err(|error| ProviderError::Io(error.to_string()))
98
+ }
99
+
100
+ fn rewrite_snapshot(
101
+ source: &str,
102
+ source_session_id: &SessionId,
103
+ target_session_id: &SessionId,
104
+ source_agent_id: &str,
105
+ target_agent_id: &str,
106
+ ) -> Result<String, ProviderError> {
107
+ let source_marker = format!("You are Team Agent worker `{source_agent_id}`");
108
+ let target_marker = format!("You are Team Agent worker `{target_agent_id}`");
109
+ let mut meta_count = 0_usize;
110
+ let mut marker_count = 0_usize;
111
+ let mut output = String::new();
112
+ for (index, line) in source.lines().enumerate() {
113
+ if line.trim().is_empty() {
114
+ continue;
115
+ }
116
+ let mut record = serde_json::from_str::<serde_json::Value>(line).map_err(|error| {
117
+ ProviderError::Io(format!(
118
+ "codex fork source has invalid JSONL at line {}: {error}",
119
+ index + 1
120
+ ))
121
+ })?;
122
+ if record.get("type").and_then(serde_json::Value::as_str) == Some("session_meta") {
123
+ let id = record
124
+ .get_mut("payload")
125
+ .and_then(serde_json::Value::as_object_mut)
126
+ .and_then(|payload| payload.get_mut("id"))
127
+ .and_then(|id| id.as_str())
128
+ .ok_or_else(|| {
129
+ ProviderError::Io(
130
+ "codex fork session_meta has no string payload.id".to_string(),
131
+ )
132
+ })?;
133
+ if id != source_session_id.as_str() {
134
+ return Err(ProviderError::Io(
135
+ "codex fork session_meta id does not match source".to_string(),
136
+ ));
137
+ }
138
+ record["payload"]["id"] =
139
+ serde_json::Value::String(target_session_id.as_str().to_string());
140
+ meta_count += 1;
141
+ }
142
+ marker_count += replace_exact_marker(&mut record, &source_marker, &target_marker);
143
+ output.push_str(
144
+ &serde_json::to_string(&record)
145
+ .map_err(|error| ProviderError::Io(error.to_string()))?,
146
+ );
147
+ output.push('\n');
148
+ }
149
+ if meta_count != 1 || marker_count != 1 {
150
+ return Err(ProviderError::Io(format!(
151
+ "codex fork source identity is ambiguous: session_meta={meta_count}, marker={marker_count}"
152
+ )));
153
+ }
154
+ Ok(output)
155
+ }
156
+
157
+ fn replace_exact_marker(value: &mut serde_json::Value, source: &str, target: &str) -> usize {
158
+ match value {
159
+ serde_json::Value::String(text) => {
160
+ let count = text.matches(source).count();
161
+ if count == 1 {
162
+ *text = text.replacen(source, target, 1);
163
+ }
164
+ count
165
+ }
166
+ serde_json::Value::Array(items) => items
167
+ .iter_mut()
168
+ .map(|item| replace_exact_marker(item, source, target))
169
+ .sum(),
170
+ serde_json::Value::Object(fields) => fields
171
+ .values_mut()
172
+ .map(|item| replace_exact_marker(item, source, target))
173
+ .sum(),
174
+ _ => 0,
175
+ }
176
+ }
177
+
178
+ fn publish_target_no_clobber(path: &Path, bytes: &[u8]) -> Result<(), ProviderError> {
179
+ let parent = path.parent().ok_or_else(|| {
180
+ ProviderError::Io(format!("codex target has no parent: {}", path.display()))
181
+ })?;
182
+ let temp = parent.join(format!(
183
+ ".{}.tmp-{}",
184
+ path.file_name()
185
+ .and_then(|name| name.to_str())
186
+ .unwrap_or("codex-fork"),
187
+ std::process::id()
188
+ ));
189
+ let result = (|| -> std::io::Result<()> {
190
+ let mut file = OpenOptions::new()
191
+ .write(true)
192
+ .create_new(true)
193
+ .open(&temp)?;
194
+ file.write_all(bytes)?;
195
+ file.sync_all()?;
196
+ std::fs::hard_link(&temp, path)?;
197
+ File::open(parent)?.sync_all()?;
198
+ std::fs::remove_file(&temp)?;
199
+ Ok(())
200
+ })();
201
+ if let Err(error) = result {
202
+ let _ = std::fs::remove_file(&temp);
203
+ return Err(ProviderError::Io(error.to_string()));
204
+ }
205
+ Ok(())
206
+ }
207
+
208
+ fn validate_materialized_target(
209
+ path: &Path,
210
+ session_id: &SessionId,
211
+ target_agent_id: &str,
212
+ ) -> Result<(), ProviderError> {
213
+ let text =
214
+ std::fs::read_to_string(path).map_err(|error| ProviderError::Io(error.to_string()))?;
215
+ let actual = session_id_from_jsonl(path);
216
+ let marker = format!("You are Team Agent worker `{target_agent_id}`");
217
+ if actual.as_deref() != Some(session_id.as_str())
218
+ || !path
219
+ .file_stem()
220
+ .and_then(|name| name.to_str())
221
+ .is_some_and(|name| name.ends_with(session_id.as_str()))
222
+ || text.matches(&marker).count() != 1
223
+ {
224
+ return Err(ProviderError::Io(
225
+ "codex fork target read-back identity mismatch".to_string(),
226
+ ));
227
+ }
228
+ Ok(())
229
+ }
230
+
231
+ fn apply_resume_target(
232
+ plan: &mut CommandPlan,
233
+ source_session_id: &SessionId,
234
+ target_session_id: &SessionId,
235
+ ) -> Result<(), ProviderError> {
236
+ if plan.argv.first().map(String::as_str) != Some("codex")
237
+ || plan.argv.get(1).map(String::as_str) != Some("fork")
238
+ || plan.argv.last().map(String::as_str) != Some(source_session_id.as_str())
239
+ {
240
+ return Err(ProviderError::Command(
241
+ "codex fork command shape cannot be converted to exact resume".to_string(),
242
+ ));
243
+ }
244
+ plan.argv[1] = "resume".to_string();
245
+ *plan.argv.last_mut().expect("validated non-empty argv") =
246
+ target_session_id.as_str().to_string();
247
+ plan.expected_session_id = Some(target_session_id.clone());
248
+ Ok(())
249
+ }
250
+
251
+ fn codex_session_v7() -> String {
252
+ use sha2::Digest;
253
+
254
+ static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
255
+
256
+ let millis = std::time::SystemTime::now()
257
+ .duration_since(std::time::UNIX_EPOCH)
258
+ .map_or(0, |duration| duration.as_millis() as u64);
259
+ let mut hasher = sha2::Sha256::new();
260
+ hasher.update(millis.to_le_bytes());
261
+ hasher.update(std::process::id().to_le_bytes());
262
+ hasher.update(
263
+ COUNTER
264
+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
265
+ .to_le_bytes(),
266
+ );
267
+ let digest = hasher.finalize();
268
+ let mut bytes = [0_u8; 16];
269
+ bytes[..6].copy_from_slice(&millis.to_be_bytes()[2..]);
270
+ bytes[6..].copy_from_slice(&digest[..10]);
271
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
272
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
273
+ format!(
274
+ "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
275
+ u32::from_be_bytes(bytes[0..4].try_into().expect("fixed slice")),
276
+ u16::from_be_bytes(bytes[4..6].try_into().expect("fixed slice")),
277
+ u16::from_be_bytes(bytes[6..8].try_into().expect("fixed slice")),
278
+ u16::from_be_bytes(bytes[8..10].try_into().expect("fixed slice")),
279
+ u64::from_be_bytes([
280
+ 0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
281
+ ])
282
+ )
283
+ }
2
284
 
3
285
  pub(super) fn verify_codex_fork(
4
286
  source_session_id: &SessionId,
5
287
  plan: &CommandPlan,
6
288
  before: &ContextBackingSnapshot,
289
+ expected_backing_path: Option<&Path>,
7
290
  agent_id: &str,
8
291
  spawn_cwd: &Path,
9
292
  spawned_at: &str,
10
293
  deadline: Duration,
11
294
  ) -> Result<ContextForkProof, ContextForkTermination> {
295
+ let Some(expected) = plan.expected_session_id.as_ref() else {
296
+ return verify_legacy_codex_fork(
297
+ source_session_id,
298
+ plan,
299
+ before,
300
+ agent_id,
301
+ spawn_cwd,
302
+ spawned_at,
303
+ deadline,
304
+ );
305
+ };
306
+ if expected == source_session_id {
307
+ return Err(ProviderError::CaptureFailed(
308
+ "context_fork_unverified: codex target session equals source".to_string(),
309
+ )
310
+ .into());
311
+ }
312
+ let expected_path = expected_backing_path.ok_or_else(|| {
313
+ ProviderError::CaptureFailed(
314
+ "context_fork_unverified: codex plan has no materialized target backing".to_string(),
315
+ )
316
+ })?;
12
317
  let context = crate::provider::session_scan::CaptureSessionContext {
13
318
  agent_id: agent_id.to_string(),
14
319
  spawn_cwd: spawn_cwd.to_path_buf(),
@@ -18,28 +323,25 @@ pub(super) fn verify_codex_fork(
18
323
  expected_session_id: plan.expected_session_id.clone(),
19
324
  provider_projects_root: plan.provider_projects_root.clone(),
20
325
  };
21
- let excluded = outcome::source_exclusions(before, source_session_id);
22
326
  let started = std::time::Instant::now();
23
327
  loop {
24
- let current = jsonl_files(&before.root);
25
328
  for candidate in
26
329
  crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
27
330
  {
28
331
  let Some(path) = candidate.captured.rollout_path.as_ref() else {
29
332
  continue;
30
333
  };
31
- let Some(stamp) = current.get(path.as_path()) else {
32
- continue;
33
- };
34
- let snapshot_changed = before.files.get(path.as_path()) != Some(stamp);
35
- if !snapshot_changed {
334
+ if path.as_path() != expected_path {
36
335
  continue;
37
336
  }
38
337
  let Some(new_session_id) = candidate.captured.session_id else {
39
338
  continue;
40
339
  };
41
- if excluded.contains(new_session_id.as_str())
42
- || excluded.contains(&path.as_path().to_string_lossy().to_string())
340
+ if &new_session_id != expected || &new_session_id == source_session_id {
341
+ continue;
342
+ }
343
+ if !candidate.positive_agent_id_match
344
+ || candidate.embedded_agent_id.as_deref() != Some(agent_id)
43
345
  {
44
346
  continue;
45
347
  }
@@ -63,3 +365,78 @@ pub(super) fn verify_codex_fork(
63
365
  deadline_ms: deadline.as_millis(),
64
366
  })
65
367
  }
368
+
369
+ fn verify_legacy_codex_fork(
370
+ source_session_id: &SessionId,
371
+ plan: &CommandPlan,
372
+ before: &ContextBackingSnapshot,
373
+ agent_id: &str,
374
+ spawn_cwd: &Path,
375
+ spawned_at: &str,
376
+ deadline: Duration,
377
+ ) -> Result<ContextForkProof, ContextForkTermination> {
378
+ let mut excluded_session_ids = BTreeSet::new();
379
+ excluded_session_ids.insert(source_session_id.as_str().to_string());
380
+ let mut excluded_backing_paths = BTreeSet::new();
381
+ for path in before.files.keys() {
382
+ if session_id_from_jsonl(path).as_deref() == Some(source_session_id.as_str()) {
383
+ excluded_backing_paths.insert(path.clone());
384
+ }
385
+ }
386
+ let context = crate::provider::session_scan::CaptureSessionContext {
387
+ agent_id: agent_id.to_string(),
388
+ spawn_cwd: spawn_cwd.to_path_buf(),
389
+ pane_id: None,
390
+ pane_pid: None,
391
+ spawned_at: Some(spawned_at.to_string()),
392
+ expected_session_id: None,
393
+ provider_projects_root: plan.provider_projects_root.clone(),
394
+ };
395
+ let started = std::time::Instant::now();
396
+ loop {
397
+ let current = jsonl_files(&provider_backing_root(Provider::Codex, plan));
398
+ let mut matches = Vec::new();
399
+ for candidate in
400
+ crate::provider::session_scan::scan_session_candidates_once(Provider::Codex, &context)?
401
+ {
402
+ let Some(path) = candidate.captured.rollout_path.as_ref() else {
403
+ continue;
404
+ };
405
+ if excluded_backing_paths.contains(path.as_path()) {
406
+ continue;
407
+ }
408
+ let Some(stamp) = current.get(path.as_path()) else {
409
+ continue;
410
+ };
411
+ if before.files.get(path.as_path()) == Some(stamp) {
412
+ continue;
413
+ }
414
+ let Some(session_id) = candidate.captured.session_id else {
415
+ continue;
416
+ };
417
+ if excluded_session_ids.contains(session_id.as_str()) {
418
+ continue;
419
+ }
420
+ matches.push((session_id, path.as_path().to_path_buf()));
421
+ }
422
+ if let [(new_session_id, backing_path)] = matches.as_slice() {
423
+ return Ok(ContextForkProof {
424
+ provider: Provider::Codex,
425
+ source_session_id: source_session_id.clone(),
426
+ new_session_id: new_session_id.clone(),
427
+ backing_path: backing_path.clone(),
428
+ captured_via: "context_fork_verified".to_string(),
429
+ attribution_confidence: "high".to_string(),
430
+ managed_backing_root: None,
431
+ });
432
+ }
433
+ if started.elapsed() >= deadline {
434
+ break;
435
+ }
436
+ std::thread::sleep(Duration::from_millis(50));
437
+ }
438
+ Err(ContextForkTermination::Timeout {
439
+ provider: Provider::Codex,
440
+ deadline_ms: deadline.as_millis(),
441
+ })
442
+ }
@@ -1,18 +1,4 @@
1
1
  use super::*;
2
- use std::collections::BTreeSet;
3
-
4
- pub(super) fn source_exclusions(
5
- before: &ContextBackingSnapshot,
6
- source_session_id: &SessionId,
7
- ) -> BTreeSet<String> {
8
- let mut excluded = BTreeSet::from([source_session_id.as_str().to_string()]);
9
- for path in before.files.keys() {
10
- if session_id_from_jsonl(path).as_deref() == Some(source_session_id.as_str()) {
11
- excluded.insert(path.to_string_lossy().to_string());
12
- }
13
- }
14
- excluded
15
- }
16
2
 
17
3
  #[derive(Debug, Clone)]
18
4
  pub(crate) struct PendingContextFork {
@@ -47,6 +33,7 @@ pub(crate) fn observe_context_fork(
47
33
  plan: &CommandPlan,
48
34
  before: &ContextBackingSnapshot,
49
35
  expected_backing_path: Option<&Path>,
36
+ source_agent_id: &str,
50
37
  agent_id: &str,
51
38
  spawn_cwd: &Path,
52
39
  spawned_at: &str,
@@ -58,6 +45,7 @@ pub(crate) fn observe_context_fork(
58
45
  plan,
59
46
  before,
60
47
  expected_backing_path,
48
+ source_agent_id,
61
49
  agent_id,
62
50
  spawn_cwd,
63
51
  spawned_at,
@@ -123,6 +111,7 @@ mod tests {
123
111
  &plan,
124
112
  &before,
125
113
  None,
114
+ "source",
126
115
  "fork",
127
116
  &root,
128
117
  "2026-07-24T00:00:00Z",
@@ -21,6 +21,7 @@ pub(crate) enum ContextForkTermination {
21
21
  mod claude;
22
22
  mod codex;
23
23
  mod outcome;
24
+ pub(crate) use codex::materialize_codex_fork;
24
25
  pub(crate) use outcome::{
25
26
  observe_context_fork, transition_pending_context_fork, ContextForkOutcome, PendingContextFork,
26
27
  };
@@ -47,7 +48,6 @@ pub struct ContextForkProof {
47
48
 
48
49
  #[derive(Debug, Clone)]
49
50
  pub(crate) struct ContextBackingSnapshot {
50
- root: PathBuf,
51
51
  files: BTreeMap<PathBuf, FileStamp>,
52
52
  }
53
53
 
@@ -61,7 +61,7 @@ impl ContextBackingSnapshot {
61
61
  pub(crate) fn capture(provider: Provider, plan: &CommandPlan) -> Self {
62
62
  let root = provider_backing_root(provider, plan);
63
63
  let files = jsonl_files(&root);
64
- Self { root, files }
64
+ Self { files }
65
65
  }
66
66
  }
67
67
 
@@ -71,6 +71,7 @@ pub(crate) fn verify_context_fork(
71
71
  plan: &CommandPlan,
72
72
  before: &ContextBackingSnapshot,
73
73
  expected_backing_path: Option<&Path>,
74
+ _source_agent_id: &str,
74
75
  agent_id: &str,
75
76
  spawn_cwd: &Path,
76
77
  spawned_at: &str,
@@ -84,6 +85,7 @@ pub(crate) fn verify_context_fork(
84
85
  source_session_id,
85
86
  plan,
86
87
  before,
88
+ expected_backing_path,
87
89
  agent_id,
88
90
  spawn_cwd,
89
91
  spawned_at,
@@ -298,6 +300,7 @@ mod tests {
298
300
  &plan,
299
301
  &before,
300
302
  None,
303
+ "source",
301
304
  "fork",
302
305
  &current_cwd,
303
306
  "2026-07-21T20:00:00+00:00",
@@ -344,6 +347,7 @@ mod tests {
344
347
  &plan,
345
348
  &before,
346
349
  Some(&expected_path),
350
+ "source",
347
351
  "fork",
348
352
  &root,
349
353
  "2026-07-22T00:00:00Z",
@@ -382,6 +386,7 @@ mod tests {
382
386
  &plan,
383
387
  &before,
384
388
  Some(&missing),
389
+ "source",
385
390
  "fork",
386
391
  &root,
387
392
  "2026-07-22T00:00:00Z",
@@ -10,8 +10,9 @@ pub mod resume;
10
10
 
11
11
  pub use context_fork::ContextForkProof;
12
12
  pub(crate) use context_fork::{
13
- context_fork_convergence_deadline, observe_context_fork, transition_pending_context_fork,
14
- ContextBackingSnapshot, ContextForkOutcome, PendingContextFork,
13
+ context_fork_convergence_deadline, materialize_codex_fork, observe_context_fork,
14
+ transition_pending_context_fork, ContextBackingSnapshot, ContextForkOutcome,
15
+ PendingContextFork,
15
16
  };
16
17
 
17
18
  pub use resume::{
@@ -1,6 +1,7 @@
1
1
  use std::path::{Path, PathBuf};
2
2
 
3
- use crate::provider::types::SessionId;
3
+ use crate::provider::helpers::find_session_id;
4
+ use crate::provider::types::{CaptureVia, CapturedSession, Confidence, RolloutPath, SessionId};
4
5
  use crate::provider::Provider;
5
6
 
6
7
  use super::{CaptureSessionContext, CapturedSessionCandidate};
@@ -14,7 +15,7 @@ pub(crate) fn projects_dir_for_cwd(home: &Path, spawn_cwd: &Path) -> Option<Path
14
15
  Some(home.join(".claude").join("projects").join(encoded))
15
16
  }
16
17
 
17
- fn encode_projects_dir(path: &str) -> String {
18
+ pub(super) fn encode_projects_dir(path: &str) -> String {
18
19
  let mut out = String::with_capacity(path.len());
19
20
  for c in path.chars() {
20
21
  if c.is_ascii_alphanumeric() {
@@ -26,6 +27,65 @@ fn encode_projects_dir(path: &str) -> String {
26
27
  out
27
28
  }
28
29
 
30
+ pub(super) fn scan_expected_session(
31
+ context: &CaptureSessionContext,
32
+ ) -> Vec<CapturedSessionCandidate> {
33
+ let Some(expected) = context.expected_session_id.as_ref() else {
34
+ return Vec::new();
35
+ };
36
+ let Some(projects_root) = context.provider_projects_root.clone().or_else(|| {
37
+ std::env::var_os("HOME")
38
+ .map(PathBuf::from)
39
+ .map(|home| home.join(".claude").join("projects"))
40
+ }) else {
41
+ return Vec::new();
42
+ };
43
+ let canonical =
44
+ std::fs::canonicalize(&context.spawn_cwd).unwrap_or_else(|_| context.spawn_cwd.clone());
45
+ let encoded = encode_projects_dir(&canonical.to_string_lossy());
46
+ if encoded.is_empty() {
47
+ return Vec::new();
48
+ }
49
+ let path = projects_root
50
+ .join(encoded)
51
+ .join(format!("{}.jsonl", expected.as_str()));
52
+ let Ok(text) = super::common::read_head_text(&path, super::common::CAPTURE_HEAD_BYTES) else {
53
+ return Vec::new();
54
+ };
55
+ let records = super::common::parse_session_records(&text);
56
+ let session_matches = records
57
+ .iter()
58
+ .find_map(find_session_id)
59
+ .is_some_and(|session_id| session_id == expected.as_str());
60
+ let has_lifecycle_record = records.iter().any(|record| {
61
+ matches!(
62
+ record.get("type").and_then(serde_json::Value::as_str),
63
+ Some("user" | "assistant")
64
+ )
65
+ });
66
+ if !session_matches
67
+ || !has_lifecycle_record
68
+ || records_have_leader_marker(&records)
69
+ || !records.iter().any(has_cwd_field)
70
+ {
71
+ return Vec::new();
72
+ }
73
+ let embedded_agent_id = super::common::embedded_team_agent_worker_id_from_text(&text);
74
+ let positive_agent_id_match = embedded_agent_id.as_deref() == Some(context.agent_id.as_str());
75
+ vec![CapturedSessionCandidate {
76
+ captured: CapturedSession {
77
+ session_id: Some(expected.clone()),
78
+ rollout_path: Some(RolloutPath::new(path)),
79
+ captured_via: CaptureVia::FsWatch,
80
+ attribution_confidence: Confidence::High,
81
+ spawn_cwd: context.spawn_cwd.clone(),
82
+ },
83
+ embedded_agent_id,
84
+ positive_agent_id_match,
85
+ agent_path_match: false,
86
+ }]
87
+ }
88
+
29
89
  pub(crate) fn rollout_path_has_leader_marker(provider: Provider, rollout_path: &Path) -> bool {
30
90
  if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
31
91
  return false;
@@ -112,6 +172,7 @@ mod tests {
112
172
  std::fs::create_dir_all(dir).unwrap();
113
173
  let path = dir.join(format!("{uuid}.jsonl"));
114
174
  let line = serde_json::json!({
175
+ "type": "user",
115
176
  "sessionId": uuid,
116
177
  "cwd": cwd.to_string_lossy(),
117
178
  });
@@ -119,6 +180,11 @@ mod tests {
119
180
  path
120
181
  }
121
182
 
183
+ fn expected_transcript_dir(projects_root: &Path, cwd: &Path) -> PathBuf {
184
+ let canonical = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
185
+ projects_root.join(encode_projects_dir(&canonical.to_string_lossy()))
186
+ }
187
+
122
188
  #[test]
123
189
  fn claude_leader_marker_in_custom_title_is_detected() {
124
190
  let records = vec![serde_json::json!({
@@ -205,7 +271,11 @@ mod tests {
205
271
  std::fs::create_dir_all(&cwd).unwrap();
206
272
  let proj = base.join("projects");
207
273
  write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
208
- write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
274
+ let expected = write_transcript(
275
+ &expected_transcript_dir(&proj, &cwd),
276
+ "22222222-2222-4222-8222-222222222222",
277
+ &cwd,
278
+ );
209
279
  let ctx = CaptureSessionContext {
210
280
  agent_id: "w1".to_string(),
211
281
  spawn_cwd: cwd.clone(),
@@ -221,6 +291,10 @@ mod tests {
221
291
  out[0].captured.session_id.as_ref().unwrap().as_str(),
222
292
  "22222222-2222-4222-8222-222222222222"
223
293
  );
294
+ assert_eq!(
295
+ out[0].captured.rollout_path.as_ref().unwrap().as_path(),
296
+ expected
297
+ );
224
298
  let _ = std::fs::remove_dir_all(&base);
225
299
  }
226
300
 
@@ -284,6 +358,21 @@ mod tests {
284
358
  let proj = base.join("projects");
285
359
  let leader = write_transcript(&proj, "11111111-1111-4111-8111-111111111111", &cwd);
286
360
  let stale = write_transcript(&proj, "22222222-2222-4222-8222-222222222222", &cwd);
361
+ let addressed =
362
+ expected_transcript_dir(&proj, &cwd).join("99999999-9999-4999-8999-999999999999.jsonl");
363
+ std::fs::create_dir_all(addressed.parent().unwrap()).unwrap();
364
+ std::fs::write(
365
+ &addressed,
366
+ format!(
367
+ "{}\n",
368
+ serde_json::json!({
369
+ "type": "user",
370
+ "sessionId": "22222222-2222-4222-8222-222222222222",
371
+ "cwd": cwd.to_string_lossy(),
372
+ })
373
+ ),
374
+ )
375
+ .unwrap();
287
376
  let _ = (leader, stale);
288
377
  let ctx = CaptureSessionContext {
289
378
  agent_id: "claude-worker".to_string(),