@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
@@ -130,7 +130,7 @@ fn leader_start_plan_external_leader_keeps_exec_provider_in_tmux() {
130
130
  std::fs::create_dir_all(&ws).unwrap();
131
131
 
132
132
  let provider_args = vec!["--".to_string(), "--model".to_string(), "opus".to_string()];
133
- let plan = leader_start_plan(
133
+ let plan = crate::leader::start::leader_start_plan_after_ambient_authority_check(
134
134
  Provider::Fake,
135
135
  &provider_args,
136
136
  &ws,
@@ -202,7 +202,7 @@ fn managed_leader_uses_dedicated_leader_session_independent_of_state_session_nam
202
202
 
203
203
  #[test]
204
204
  #[serial_test::serial(env)]
205
- fn in_tmux_default_leader_runs_provider_in_current_pane() {
205
+ fn in_tmux_default_leader_uses_managed_client_and_external_opt_out_keeps_exec_provider() {
206
206
  let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
207
207
  let ws = std::env::temp_dir().join(format!("ta_rs_lsp_switch_{}", std::process::id()));
208
208
  std::fs::create_dir_all(&ws).unwrap();
@@ -210,26 +210,65 @@ fn in_tmux_default_leader_runs_provider_in_current_pane() {
210
210
  let endpoint = format!("/private/tmp/tmux-501/{socket},88432,187");
211
211
  let _e = EnvGuard::apply(&[("TMUX", Some(&endpoint)), ("TMUX_PANE", Some("%7"))]);
212
212
 
213
- let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
213
+ let managed = crate::leader::start::leader_start_plan_after_ambient_authority_check(
214
+ Provider::Fake,
215
+ &[],
216
+ &ws,
217
+ false,
218
+ false,
219
+ None,
220
+ false,
221
+ )
222
+ .unwrap();
214
223
 
215
- assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
216
- assert!(!plan.is_external_leader);
217
- assert!(plan.session_name.is_none());
218
- assert_eq!(plan.leader_window, None);
219
- assert_eq!(plan.argv, vec!["fake".to_string()]);
224
+ assert_eq!(
225
+ managed.mode,
226
+ LeaderStartMode::ManagedTmuxClient,
227
+ "N41 managed default: an in-tmux caller on the workspace server must use the managed client path"
228
+ );
229
+ assert!(!managed.is_external_leader);
230
+ assert!(managed.session_name.is_some());
231
+ assert_eq!(
232
+ managed.leader_window.as_ref().map(WindowName::as_str),
233
+ Some("fake")
234
+ );
220
235
  assert!(
221
- !plan
236
+ managed.argv.iter().any(|arg| arg == "switch-client")
237
+ && !managed.argv.iter().any(|arg| arg == "attach-session"),
238
+ "N41 managed default: same-server dispatch must switch the existing client, not nested-attach: {:?}",
239
+ managed.argv
240
+ );
241
+ assert_eq!(managed.provider_argv, vec!["fake".to_string()]);
242
+
243
+ let external = crate::leader::start::leader_start_plan_after_ambient_authority_check(
244
+ Provider::Fake,
245
+ &[],
246
+ &ws,
247
+ false,
248
+ false,
249
+ None,
250
+ true,
251
+ )
252
+ .unwrap();
253
+
254
+ assert_eq!(external.mode, LeaderStartMode::ExecProvider);
255
+ assert!(external.is_external_leader);
256
+ assert_eq!(external.leader_window, None);
257
+ assert_eq!(external.argv, vec!["fake".to_string()]);
258
+ assert_eq!(external.provider_argv, vec!["fake".to_string()]);
259
+ assert!(
260
+ !external
222
261
  .argv
223
262
  .iter()
224
263
  .any(|arg| arg == "switch-client" || arg == "attach-session"),
225
- "in-tmux default launch must not create or attach a background leader session: {:?}",
226
- plan.argv
264
+ "N41 external opt-out: explicit external topology must remain a direct provider path: {:?}",
265
+ external.argv
227
266
  );
228
267
  }
229
268
 
230
269
  #[test]
231
270
  #[serial_test::serial(env)]
232
- fn in_tmux_default_leader_does_not_refuse_different_tmux_server() {
271
+ fn in_tmux_external_opt_out_keeps_exec_provider_across_server_boundary() {
233
272
  let _g = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
234
273
  let _e = EnvGuard::apply(&[
235
274
  ("TMUX", Some("/private/tmp/tmux-501/default,88432,187")),
@@ -238,12 +277,21 @@ fn in_tmux_default_leader_does_not_refuse_different_tmux_server() {
238
277
  let ws = std::env::temp_dir().join(format!("ta_rs_lsp_refuse_{}", std::process::id()));
239
278
  std::fs::create_dir_all(&ws).unwrap();
240
279
 
241
- let plan = leader_start_plan(Provider::Fake, &[], &ws, false, false, None, false).unwrap();
280
+ let plan = crate::leader::start::leader_start_plan_after_ambient_authority_check(
281
+ Provider::Fake,
282
+ &[],
283
+ &ws,
284
+ false,
285
+ false,
286
+ None,
287
+ true,
288
+ )
289
+ .unwrap();
242
290
 
243
291
  assert_eq!(plan.mode, LeaderStartMode::ExecProvider);
244
- assert!(!plan.is_external_leader);
245
- assert!(plan.session_name.is_none());
292
+ assert!(plan.is_external_leader);
246
293
  assert_eq!(plan.argv, vec!["fake".to_string()]);
294
+ assert_eq!(plan.provider_argv, vec!["fake".to_string()]);
247
295
  }
248
296
 
249
297
  #[test]
@@ -420,45 +468,4 @@ fn p2_leader_detect_divergence_catches_owner_uuid_split() {
420
468
  // refactor must also update these tests.
421
469
  // ═══════════════════════════════════════════════════════════════════════
422
470
 
423
- #[test]
424
- fn unit0_leader_session_prefix_constant_is_stable() {
425
- // Pinned: the literal leader session prefix that gates "is this a
426
- // leader session" decisions everywhere in the runtime.
427
- assert_eq!(
428
- crate::leader::start::LEADER_SESSION_PREFIX,
429
- "team-agent-leader-"
430
- );
431
- // The layout module re-exports the same value (kept in sync via
432
- // const re-export).
433
- assert_eq!(
434
- crate::layout::sessions::LEADER_SESSION_PREFIX,
435
- crate::leader::start::LEADER_SESSION_PREFIX
436
- );
437
- }
438
-
439
- #[test]
440
- fn unit0_leader_prefixed_name_must_never_be_taken_as_worker_session() {
441
- // Pinned invariant fed into unit-1 typed identity: any session
442
- // name starting with `LEADER_SESSION_PREFIX` is a leader launcher
443
- // session, never a worker session. unit-1 will wrap this rule in
444
- // typed constructors (`WorkerSession::new` rejects the prefix,
445
- // `LeaderLauncherSession::new` requires it).
446
- let leader = "team-agent-leader-claude-x-aaaaaaaaaaaa";
447
- let worker = "team-real-worker";
448
- let prefix = crate::leader::start::LEADER_SESSION_PREFIX;
449
- assert!(leader.starts_with(prefix));
450
- assert!(!worker.starts_with(prefix));
451
- // Symmetry: the runtime today uses this exact check
452
- // (cli/mod.rs:261 `starts_with(LEADER_SESSION_PREFIX)`) to spare
453
- // leader sessions during shutdown.
454
- let leader_name = crate::transport::SessionName::new(leader);
455
- let worker_name = crate::transport::SessionName::new(worker);
456
- assert!(
457
- crate::layout::sessions::is_leader_session(&leader_name),
458
- "is_leader_session must accept a leader-prefixed session",
459
- );
460
- assert!(
461
- !crate::layout::sessions::is_leader_session(&worker_name),
462
- "is_leader_session must reject a non-prefixed session",
463
- );
464
- }
471
+ include!("identity_session_names.rs");
@@ -0,0 +1,42 @@
1
+ #[test]
2
+ fn unit0_leader_session_prefix_constant_is_stable() {
3
+ // Pinned: the literal leader session prefix that gates "is this a
4
+ // leader session" decisions everywhere in the runtime.
5
+ assert_eq!(
6
+ crate::leader::start::LEADER_SESSION_PREFIX,
7
+ "team-agent-leader-"
8
+ );
9
+ // The layout module re-exports the same value (kept in sync via
10
+ // const re-export).
11
+ assert_eq!(
12
+ crate::layout::sessions::LEADER_SESSION_PREFIX,
13
+ crate::leader::start::LEADER_SESSION_PREFIX
14
+ );
15
+ }
16
+
17
+ #[test]
18
+ fn unit0_leader_prefixed_name_must_never_be_taken_as_worker_session() {
19
+ // Pinned invariant fed into unit-1 typed identity: any session
20
+ // name starting with `LEADER_SESSION_PREFIX` is a leader launcher
21
+ // session, never a worker session. unit-1 will wrap this rule in
22
+ // typed constructors (`WorkerSession::new` rejects the prefix,
23
+ // `LeaderLauncherSession::new` requires it).
24
+ let leader = "team-agent-leader-claude-x-aaaaaaaaaaaa";
25
+ let worker = "team-real-worker";
26
+ let prefix = crate::leader::start::LEADER_SESSION_PREFIX;
27
+ assert!(leader.starts_with(prefix));
28
+ assert!(!worker.starts_with(prefix));
29
+ // Symmetry: the runtime today uses this exact check
30
+ // (cli/mod.rs:261 `starts_with(LEADER_SESSION_PREFIX)`) to spare
31
+ // leader sessions during shutdown.
32
+ let leader_name = crate::transport::SessionName::new(leader);
33
+ let worker_name = crate::transport::SessionName::new(worker);
34
+ assert!(
35
+ crate::layout::sessions::is_leader_session(&leader_name),
36
+ "is_leader_session must accept a leader-prefixed session",
37
+ );
38
+ assert!(
39
+ !crate::layout::sessions::is_leader_session(&worker_name),
40
+ "is_leader_session must reject a non-prefixed session",
41
+ );
42
+ }
@@ -50,6 +50,10 @@ pub mod state;
50
50
  // step 6 (compiler) — TEAM.md + agents/*.md → 规范 team.spec dict(doc→spec 纯变换)。
51
51
  pub mod compiler;
52
52
 
53
+ // Official communication-mode catalog. Configuration compilation and every
54
+ // future projection consumer must use this typed single source of truth.
55
+ pub mod communication_mode;
56
+
53
57
  // step 7 (message_store) — team.db 上的核心消息生命周期(create/claim/mark/通知去重)。
54
58
  // unit-10 (Stage 4) compat shim. Physical home is now `crate::db::message_store`.
55
59
  pub use crate::db::message_store;
@@ -0,0 +1,58 @@
1
+ use super::super::*;
2
+
3
+ pub(super) struct CompleteForkInput<'a> {
4
+ pub workspace: &'a Path,
5
+ pub team_key: &'a str,
6
+ pub agent_id: &'a AgentId,
7
+ pub spawn: &'a crate::transport::SpawnResult,
8
+ pub window: &'a WindowName,
9
+ pub transport: &'a dyn Transport,
10
+ pub session_name: &'a SessionName,
11
+ pub mcp_config_path: &'a Path,
12
+ pub profile_launch: &'a crate::provider::ProviderProfileLaunch,
13
+ pub materialized_role: &'a mut MaterializedRole,
14
+ pub claude_fork:
15
+ &'a mut Option<crate::provider::adapters::claude_fork::ClaudeForkMaterialization>,
16
+ pub copilot_fork:
17
+ &'a mut Option<crate::provider::adapters::copilot_fork::CopilotForkMaterialization>,
18
+ }
19
+
20
+ pub(super) fn complete_fork(input: CompleteForkInput<'_>) -> Result<bool, LifecycleError> {
21
+ if let Err(error) = verify_fork_registration(
22
+ input.workspace,
23
+ input.team_key,
24
+ input.agent_id,
25
+ input.spawn,
26
+ input.window,
27
+ ) {
28
+ rollback_fork_after_spawn(
29
+ input.workspace,
30
+ input.transport,
31
+ input.session_name,
32
+ input.window,
33
+ input.mcp_config_path,
34
+ input.agent_id,
35
+ input.profile_launch,
36
+ input.team_key,
37
+ );
38
+ return Err(error);
39
+ }
40
+ let coordinator_started = start_fork_coordinator(ForkCoordinatorInput {
41
+ workspace: input.workspace,
42
+ team_key: input.team_key,
43
+ agent_id: input.agent_id,
44
+ transport: input.transport,
45
+ session_name: input.session_name,
46
+ window: input.window,
47
+ mcp_config_path: input.mcp_config_path,
48
+ profile_launch: input.profile_launch,
49
+ })?;
50
+ input.materialized_role.keep();
51
+ if let Some(materialized) = input.claude_fork.as_mut() {
52
+ materialized.keep();
53
+ }
54
+ if let Some(materialized) = input.copilot_fork.as_mut() {
55
+ materialized.keep();
56
+ }
57
+ Ok(coordinator_started)
58
+ }
@@ -1,16 +1,7 @@
1
1
  use super::*;
2
- use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
3
2
  use crate::lifecycle::profile_launch::parse_provider;
4
- use crate::lifecycle::*;
5
- use crate::model::enums::{AuthMode, DisplayBackend, Provider, ProviderEffort};
6
- use crate::model::ids::AgentId;
7
- use crate::model::permissions::{self, AgentPermissionInput};
8
- use crate::model::yaml::{self, Value};
9
- use crate::state::persist::load_runtime_state;
10
- use crate::transport::{PaneId, SessionName, Target, Transport, WindowName};
11
- use std::collections::{BTreeMap, BTreeSet};
12
- use std::path::{Path, PathBuf};
13
- use std::process::Command;
3
+
4
+ mod completion;
14
5
 
15
6
  pub fn fork_agent_with_transport(
16
7
  workspace: &Path,
@@ -135,7 +126,7 @@ pub fn fork_agent_with_transport(
135
126
  new_agent,
136
127
  Some(as_agent_id.as_str()),
137
128
  provider,
138
- );
129
+ )?;
139
130
  let system_prompt =
140
131
  crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
141
132
  let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
@@ -239,6 +230,23 @@ pub fn fork_agent_with_transport(
239
230
  as_agent_id.as_str(),
240
231
  Some(&fork_team),
241
232
  );
233
+ let spawned_at = spawn_timestamp_for_agent(1);
234
+ let mut fork_backing = adapter
235
+ .materialize_fork_backing(
236
+ &source_backing,
237
+ &session_id,
238
+ source_agent_id.as_str(),
239
+ as_agent_id.as_str(),
240
+ &mut plan,
241
+ )
242
+ .map_err(|error| {
243
+ let _ = std::fs::write(&spec_path, text.as_bytes());
244
+ cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
245
+ LifecycleError::Provider(error.to_string())
246
+ })?;
247
+ let mut expected_backing_path = fork_backing
248
+ .as_ref()
249
+ .map(|materialized| materialized.path().to_path_buf());
242
250
  if matches!(provider, Provider::Claude | Provider::ClaudeCode) {
243
251
  plan.provider_projects_root = source_backing.parent().map(Path::to_path_buf);
244
252
  }
@@ -248,12 +256,19 @@ pub fn fork_agent_with_transport(
248
256
  &plan,
249
257
  &source_backing,
250
258
  &session_id,
259
+ source_agent_id,
260
+ as_agent_id,
251
261
  )
252
262
  .map_err(|error| {
253
263
  let _ = std::fs::write(&spec_path, text.as_bytes());
254
264
  cleanup_fork_mcp_artifacts(&workspace, as_agent_id, &mcp_config_path, &profile_launch);
255
265
  error
256
266
  })?;
267
+ if expected_backing_path.is_none() {
268
+ expected_backing_path = claude_fork
269
+ .as_ref()
270
+ .map(|materialized| materialized.path().to_path_buf());
271
+ }
257
272
  // The framework-created Claude snapshot is only fork input, not provider
258
273
  // proof. Observe changes made after materialization so a spawn-only
259
274
  // provider cannot turn the copied source backing into a Verified result.
@@ -307,7 +322,6 @@ pub fn fork_agent_with_transport(
307
322
  // Release the metadata lock before per-seat provider convergence; finalize reacquires it.
308
323
  drop(_lock);
309
324
  let spawn_epoch = 1_u64;
310
- let spawned_at = spawn_timestamp_for_agent(u32::try_from(spawn_epoch).unwrap_or(u32::MAX));
311
325
  let spawn_result = if session_live {
312
326
  transport.spawn_into_with_env_unset(
313
327
  &session_name,
@@ -343,6 +357,9 @@ pub fn fork_agent_with_transport(
343
357
  return Err(LifecycleError::Transport(error.to_string()));
344
358
  }
345
359
  };
360
+ if let Some(materialized) = fork_backing.as_mut() {
361
+ materialized.handoff();
362
+ }
346
363
  ensure_fork_spawn_live(ForkPostSpawnInput {
347
364
  workspace: &workspace,
348
365
  transport,
@@ -361,7 +378,8 @@ pub fn fork_agent_with_transport(
361
378
  &session_id,
362
379
  &plan,
363
380
  &backing_before,
364
- claude_fork.as_ref().map(|materialized| materialized.path()),
381
+ expected_backing_path.as_deref(),
382
+ source_agent_id.as_str(),
365
383
  as_agent_id.as_str(),
366
384
  &workspace,
367
385
  &spawned_at,
@@ -414,7 +432,7 @@ pub fn fork_agent_with_transport(
414
432
  }
415
433
  };
416
434
  if let Some(context_proof) = context_proof.as_ref() {
417
- if let Err(error) = finalize_fork_state(ForkFinalizeInput {
435
+ let finalized = match finalize_fork_state(ForkFinalizeInput {
418
436
  workspace: &workspace,
419
437
  team_key: &fork_team,
420
438
  source_agent_id,
@@ -430,51 +448,39 @@ pub fn fork_agent_with_transport(
430
448
  spawned_at: &spawned_at,
431
449
  spawn_epoch,
432
450
  }) {
433
- rollback_fork_after_spawn(
434
- &workspace,
435
- transport,
436
- &session_name,
437
- &window,
438
- &mcp_config_path,
439
- as_agent_id,
440
- &profile_launch,
441
- &fork_team,
442
- );
443
- return Err(error);
444
- }
445
- }
446
- if let Err(error) =
447
- verify_fork_registration(&workspace, &fork_team, as_agent_id, &spawn, &window)
448
- {
449
- rollback_fork_after_spawn(
450
- &workspace,
451
- transport,
452
- &session_name,
453
- &window,
454
- &mcp_config_path,
455
- as_agent_id,
456
- &profile_launch,
457
- &fork_team,
458
- );
459
- return Err(error);
451
+ Ok(finalized) => finalized,
452
+ Err(error) => {
453
+ rollback_fork_after_spawn(
454
+ &workspace,
455
+ transport,
456
+ &session_name,
457
+ &window,
458
+ &mcp_config_path,
459
+ as_agent_id,
460
+ &profile_launch,
461
+ &fork_team,
462
+ );
463
+ return Err(error);
464
+ }
465
+ };
466
+ finalized
467
+ .write_audit(&crate::event_log::EventLog::new(&workspace))
468
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
460
469
  }
461
- let coordinator_started = start_fork_coordinator(ForkCoordinatorInput {
470
+ let coordinator_started = completion::complete_fork(completion::CompleteForkInput {
462
471
  workspace: &workspace,
463
472
  team_key: &fork_team,
464
473
  agent_id: as_agent_id,
474
+ spawn: &spawn,
475
+ window: &window,
465
476
  transport,
466
477
  session_name: &session_name,
467
- window: &window,
468
478
  mcp_config_path: &mcp_config_path,
469
479
  profile_launch: &profile_launch,
480
+ materialized_role: &mut materialized_role,
481
+ claude_fork: &mut claude_fork,
482
+ copilot_fork: &mut copilot_fork,
470
483
  })?;
471
- materialized_role.keep();
472
- if let Some(materialized) = claude_fork.as_mut() {
473
- materialized.keep();
474
- }
475
- if let Some(materialized) = copilot_fork.as_mut() {
476
- materialized.keep();
477
- }
478
484
  let backing_state = if context_proof.is_some() {
479
485
  ForkBackingState::Verified
480
486
  } else {
@@ -44,6 +44,8 @@ pub(super) fn prepare_claude_fork_backing(
44
44
  plan: &crate::provider::CommandPlan,
45
45
  source_backing: &Path,
46
46
  source_session_id: &crate::provider::SessionId,
47
+ source_agent_id: &AgentId,
48
+ target_agent_id: &AgentId,
47
49
  ) -> Result<Option<crate::provider::adapters::claude_fork::ClaudeForkMaterialization>, LifecycleError>
48
50
  {
49
51
  if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
@@ -56,6 +58,8 @@ pub(super) fn prepare_claude_fork_backing(
56
58
  source_backing,
57
59
  source_session_id,
58
60
  target_session_id,
61
+ source_agent_id.as_str(),
62
+ target_agent_id.as_str(),
59
63
  )
60
64
  .map(Some)
61
65
  .map_err(|error| {
@@ -122,7 +126,75 @@ pub(super) struct ForkFinalizeInput<'a> {
122
126
  pub spawn_epoch: u64,
123
127
  }
124
128
 
125
- pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), LifecycleError> {
129
+ #[derive(Debug, Clone, PartialEq, Eq)]
130
+ pub(crate) struct ContextForkFinalized {
131
+ source_agent_id: String,
132
+ agent_id: String,
133
+ session_id: String,
134
+ rollout_path: String,
135
+ captured_via: String,
136
+ attribution_confidence: String,
137
+ }
138
+
139
+ impl ContextForkFinalized {
140
+ fn new(
141
+ source_agent_id: &str,
142
+ agent_id: &str,
143
+ session_id: &str,
144
+ rollout_path: &Path,
145
+ captured_via: &str,
146
+ attribution_confidence: &str,
147
+ ) -> Self {
148
+ Self {
149
+ source_agent_id: source_agent_id.to_string(),
150
+ agent_id: agent_id.to_string(),
151
+ session_id: session_id.to_string(),
152
+ rollout_path: rollout_path.to_string_lossy().to_string(),
153
+ captured_via: captured_via.to_string(),
154
+ attribution_confidence: attribution_confidence.to_string(),
155
+ }
156
+ }
157
+
158
+ fn from_captured(
159
+ source_agent_id: &str,
160
+ agent_id: &str,
161
+ captured: &crate::provider::CapturedSession,
162
+ ) -> Option<Self> {
163
+ let captured_via = serde_json::to_value(captured.captured_via).ok()?;
164
+ let attribution_confidence = serde_json::to_value(captured.attribution_confidence).ok()?;
165
+ Some(Self::new(
166
+ source_agent_id,
167
+ agent_id,
168
+ captured.session_id.as_ref()?.as_str(),
169
+ captured.rollout_path.as_ref()?.as_path(),
170
+ captured_via.as_str()?,
171
+ attribution_confidence.as_str()?,
172
+ ))
173
+ }
174
+
175
+ pub(crate) fn write_audit(
176
+ &self,
177
+ event_log: &crate::event_log::EventLog,
178
+ ) -> Result<(), crate::event_log::EventLogError> {
179
+ event_log
180
+ .write(
181
+ crate::lifecycle::types::event_names::CONTEXT_FORK,
182
+ serde_json::json!({
183
+ "source_agent_id": self.source_agent_id,
184
+ "agent_id": self.agent_id,
185
+ "session_id": self.session_id,
186
+ "rollout_path": self.rollout_path,
187
+ "captured_via": self.captured_via,
188
+ "attribution_confidence": self.attribution_confidence,
189
+ }),
190
+ )
191
+ .map(|_| ())
192
+ }
193
+ }
194
+
195
+ pub(super) fn finalize_fork_state(
196
+ input: ForkFinalizeInput<'_>,
197
+ ) -> Result<ContextForkFinalized, LifecycleError> {
126
198
  let _lock = acquire_agent_lifecycle_lock(LifecycleLockRequest {
127
199
  workspace: input.workspace,
128
200
  operation: "fork-agent-finalize",
@@ -169,7 +241,15 @@ pub(super) fn finalize_fork_state(input: ForkFinalizeInput<'_>) -> Result<(), Li
169
241
  },
170
242
  &next_state,
171
243
  )
172
- .map_err(|error| LifecycleError::StatePersist(error.to_string()))
244
+ .map_err(|error| LifecycleError::StatePersist(error.to_string()))?;
245
+ Ok(ContextForkFinalized::new(
246
+ input.source_agent_id.as_str(),
247
+ input.agent_id.as_str(),
248
+ input.context_proof.new_session_id.as_str(),
249
+ &input.context_proof.backing_path,
250
+ &input.context_proof.captured_via,
251
+ &input.context_proof.attribution_confidence,
252
+ ))
173
253
  }
174
254
 
175
255
  pub(super) struct ForkPendingFinalizeInput<'a> {
@@ -233,20 +313,36 @@ pub(super) fn finalize_pending_fork_state(
233
313
  pub(crate) fn finalize_pending_fork_capture(
234
314
  agent: &mut serde_json::Map<String, serde_json::Value>,
235
315
  captured: &crate::provider::CapturedSession,
236
- ) -> bool {
316
+ ) -> Option<ContextForkFinalized> {
237
317
  let Some(session_id) = captured.session_id.as_ref() else {
238
- return false;
318
+ return None;
239
319
  };
240
320
  let Some(rollout_path) = captured.rollout_path.as_ref() else {
241
- return false;
321
+ return None;
242
322
  };
243
323
  if agent
244
324
  .get("fork_source_session_id")
245
325
  .and_then(serde_json::Value::as_str)
246
326
  == Some(session_id.as_str())
247
327
  {
248
- return false;
328
+ return None;
249
329
  }
330
+ let finalized = ContextForkFinalized::from_captured(
331
+ agent
332
+ .get("forked_from")
333
+ .and_then(serde_json::Value::as_str)
334
+ .unwrap_or_default(),
335
+ agent
336
+ .get("agent_id")
337
+ .and_then(serde_json::Value::as_str)
338
+ .or_else(|| {
339
+ agent
340
+ .get("pending_target_agent")
341
+ .and_then(serde_json::Value::as_str)
342
+ })
343
+ .unwrap_or_default(),
344
+ captured,
345
+ )?;
250
346
  agent.insert(
251
347
  "session_id".to_string(),
252
348
  serde_json::json!(session_id.as_str()),
@@ -269,11 +365,55 @@ pub(crate) fn finalize_pending_fork_capture(
269
365
  );
270
366
  agent.remove("_pending_session_id");
271
367
  agent.remove("attribution_ambiguous");
272
- agent.remove("fork_source_session_id");
273
368
  agent.remove("pending_target_agent");
274
369
  agent.remove("pending_grace_secs");
275
370
  agent.insert("capture_state".to_string(), serde_json::json!("captured"));
276
- true
371
+ Some(finalized)
372
+ }
373
+
374
+ #[cfg(test)]
375
+ mod tests {
376
+ use super::*;
377
+
378
+ #[test]
379
+ fn context_fork_finalized_writes_catalog_event_and_canonical_fields() {
380
+ let workspace = std::env::temp_dir().join(format!(
381
+ "team-agent-context-fork-audit-{}",
382
+ std::process::id()
383
+ ));
384
+ let _ = std::fs::remove_dir_all(&workspace);
385
+ std::fs::create_dir_all(&workspace).expect("create audit workspace");
386
+ let event_log = crate::event_log::EventLog::new(&workspace);
387
+ ContextForkFinalized::new(
388
+ "source",
389
+ "target",
390
+ "target-session",
391
+ Path::new("/tmp/target.jsonl"),
392
+ "context_fork_verified",
393
+ "high",
394
+ )
395
+ .write_audit(&event_log)
396
+ .expect("write context fork audit");
397
+
398
+ let events = event_log.tail(0).expect("read context fork audit");
399
+ let event = events.last().expect("context fork audit event");
400
+ assert_eq!(
401
+ event.get("event").and_then(serde_json::Value::as_str),
402
+ Some(crate::lifecycle::types::event_names::CONTEXT_FORK)
403
+ );
404
+ assert_eq!(
405
+ event
406
+ .get("source_agent_id")
407
+ .and_then(serde_json::Value::as_str),
408
+ Some("source")
409
+ );
410
+ assert_eq!(
411
+ event.get("agent_id").and_then(serde_json::Value::as_str),
412
+ Some("target")
413
+ );
414
+ assert!(event.get("prompt").is_none());
415
+ let _ = std::fs::remove_dir_all(workspace);
416
+ }
277
417
  }
278
418
 
279
419
  pub(super) fn verify_fork_registration(
@@ -354,6 +354,11 @@ pub(super) fn upsert_forked_agent_state(
354
354
  "forked_from".to_string(),
355
355
  serde_json::json!(source_agent_id.as_str()),
356
356
  );
357
+ entry.insert(
358
+ "fork_source_session_id".to_string(),
359
+ serde_json::json!(context_proof.source_session_id.as_str()),
360
+ );
361
+ entry.insert("capture_state".to_string(), serde_json::json!("captured"));
357
362
  entry.insert(
358
363
  "dynamic_role_file".to_string(),
359
364
  serde_json::json!(dynamic_role_file.to_string_lossy().to_string()),