@team-agent/installer 0.5.51 → 0.5.53

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 (79) 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 +17 -0
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/named_address.rs +35 -67
  7. package/crates/team-agent/src/cli/spec.rs +3 -0
  8. package/crates/team-agent/src/cli/tests/main_preserved.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +11 -0
  10. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  11. package/crates/team-agent/src/kill_audit.rs +67 -0
  12. package/crates/team-agent/src/leader/lease.rs +526 -73
  13. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  14. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  15. package/crates/team-agent/src/leader/start.rs +144 -23
  16. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  17. package/crates/team-agent/src/leader/types.rs +6 -0
  18. package/crates/team-agent/src/lib.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  20. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +12 -1
  21. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  24. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  25. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  26. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  27. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  29. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -8
  30. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  31. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  32. package/crates/team-agent/src/lifecycle/restart/agent.rs +20 -4
  33. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  34. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +113 -4
  35. package/crates/team-agent/src/lifecycle/restart/remove.rs +73 -10
  36. package/crates/team-agent/src/lifecycle/restart.rs +25 -1
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +441 -12
  38. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +20 -2
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  41. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  42. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  43. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  44. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  45. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  46. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  47. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  48. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  49. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  50. package/crates/team-agent/src/messaging/delivery.rs +126 -147
  51. package/crates/team-agent/src/messaging/leader_channel.rs +198 -0
  52. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  53. package/crates/team-agent/src/messaging/mod.rs +5 -0
  54. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  55. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  56. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  57. package/crates/team-agent/src/provider/adapter.rs +54 -13
  58. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  59. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  60. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  61. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  62. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  63. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  64. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  65. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  66. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  67. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  68. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  69. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  70. package/crates/team-agent/src/provider/tests.rs +1 -0
  71. package/crates/team-agent/src/state/persist.rs +282 -20
  72. package/crates/team-agent/src/state/projection.rs +245 -30
  73. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  74. package/crates/team-agent/src/state/repository.rs +36 -12
  75. package/crates/team-agent/src/state/selector.rs +9 -18
  76. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  77. package/crates/team-agent/src/tmux_backend.rs +29 -2
  78. package/package.json +4 -4
  79. package/skills/team-agent/SKILL.md +8 -3
package/Cargo.lock CHANGED
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.51"
578
+ version = "0.5.53"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.51"
12
+ version = "0.5.53"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -876,8 +876,10 @@ fn fake_shutdown(workspace: &Path) -> Result<Value, CliError> {
876
876
  }
877
877
  }
878
878
  }
879
- crate::state::repository::StateRepository::new(workspace)
880
- .save(crate::state::repository::StateWriteIntent::FakeE2eSeed, &state)?;
879
+ crate::state::repository::StateRepository::new(workspace).save(
880
+ crate::state::repository::StateWriteIntent::FakeE2eSeed,
881
+ &state,
882
+ )?;
881
883
  Ok(json!({
882
884
  "ok": true,
883
885
  "session_name": state.get("session_name").cloned().unwrap_or(Value::Null),
@@ -1375,6 +1377,20 @@ pub fn cmd_fork_agent(args: &ForkAgentArgs) -> Result<CmdResult, CliError> {
1375
1377
  ))
1376
1378
  }
1377
1379
 
1380
+ pub fn cmd_clone_agent(args: &CloneAgentArgs) -> Result<CmdResult, CliError> {
1381
+ Ok(CmdResult::from_json(
1382
+ lifecycle_port::clone_agent(
1383
+ &args.workspace,
1384
+ &args.source_agent,
1385
+ &args.as_agent,
1386
+ args.label.as_deref(),
1387
+ !args.no_display,
1388
+ args.team.as_deref(),
1389
+ )?,
1390
+ args.json,
1391
+ ))
1392
+ }
1393
+
1378
1394
  /// `cmd_remove_agent`(`commands.py:394`)。
1379
1395
  pub fn cmd_remove_agent(args: &RemoveAgentArgs) -> Result<CmdResult, CliError> {
1380
1396
  Ok(CmdResult::from_json(
@@ -115,6 +115,7 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
115
115
  "stop-agent" => cmd_stop_agent(&stop_agent_args(args, cwd)?).map(emit_result),
116
116
  "reset-agent" => cmd_reset_agent(&reset_agent_args(args, cwd)?).map(emit_result),
117
117
  "add-agent" => cmd_add_agent(&add_agent_args(args, cwd)?).map(emit_result),
118
+ "clone-agent" => cmd_clone_agent(&clone_agent_args(args, cwd)?).map(emit_result),
118
119
  "fork-agent" => cmd_fork_agent(&fork_agent_args(args, cwd)?).map(emit_result),
119
120
  "remove-agent" => cmd_remove_agent(&remove_agent_args(args, cwd)?).map(emit_result),
120
121
  "stuck-list" => cmd_stuck_list(&stuck_list_args(args, cwd)).map(emit_result),
@@ -330,6 +331,7 @@ fn command_help(command: Option<&str>) -> String {
330
331
  Some("start-agent") => "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]".to_string(),
331
332
  Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
332
333
  Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
334
+ Some("clone-agent") => "usage: team-agent clone-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
333
335
  Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
334
336
  Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
335
337
  // 0.5.26 (§7.6): removed from help; dispatch was never wired.
@@ -1366,6 +1368,21 @@ fn fork_agent_args(args: &[String], cwd: &Path) -> Result<ForkAgentArgs, CliErro
1366
1368
  })
1367
1369
  }
1368
1370
 
1371
+ fn clone_agent_args(args: &[String], cwd: &Path) -> Result<CloneAgentArgs, CliError> {
1372
+ let parsed = parse_args(args);
1373
+ Ok(CloneAgentArgs {
1374
+ source_agent: required_pos(&parsed, 0, "source_agent")?,
1375
+ workspace: workspace(&parsed, cwd),
1376
+ team: parsed.team,
1377
+ as_agent: parsed
1378
+ .as_agent
1379
+ .ok_or_else(|| CliError::Usage("missing --as".to_string()))?,
1380
+ label: parsed.label,
1381
+ no_display: parsed.no_display,
1382
+ json: parsed.json,
1383
+ })
1384
+ }
1385
+
1369
1386
  fn remove_agent_args(args: &[String], cwd: &Path) -> Result<RemoveAgentArgs, CliError> {
1370
1387
  let parsed = parse_args(args);
1371
1388
  let workspace = workspace(&parsed, cwd);
@@ -374,6 +374,19 @@ pub mod lifecycle_port {
374
374
  );
375
375
  let mut error = None;
376
376
  for session in &sessions {
377
+ let targets = vec![session.as_str().to_string()];
378
+ if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
379
+ event_log,
380
+ transport,
381
+ "shutdown.shared_socket_individual",
382
+ crate::kill_audit::KILL_SESSION,
383
+ &targets,
384
+ ) {
385
+ error.get_or_insert_with(|| {
386
+ format!("pre-kill audit failed: {audit_error}")
387
+ });
388
+ continue;
389
+ }
377
390
  if let Err(err) = transport.kill_session(session) {
378
391
  if !tmux_absent_error(&err.to_string()) {
379
392
  error.get_or_insert_with(|| err.to_string());
@@ -386,7 +399,20 @@ pub mod lifecycle_port {
386
399
  error,
387
400
  };
388
401
  }
389
- let error = transport.kill_server().err().map(|error| error.to_string());
402
+ let targets = sessions
403
+ .iter()
404
+ .map(|session| session.as_str().to_string())
405
+ .collect::<Vec<_>>();
406
+ let error = match crate::kill_audit::pre_kill_audit(
407
+ event_log,
408
+ transport,
409
+ "shutdown.exclusive_socket",
410
+ crate::kill_audit::KILL_SERVER,
411
+ &targets,
412
+ ) {
413
+ Ok(()) => transport.kill_server().err().map(|error| error.to_string()),
414
+ Err(error) => Some(format!("pre-kill audit failed: {error}")),
415
+ };
390
416
  ShutdownSocketCleanup {
391
417
  killed_sessions: sessions,
392
418
  spared_sessions: Vec::new(),
@@ -406,6 +432,18 @@ pub mod lifecycle_port {
406
432
  }
407
433
  let mut error = None;
408
434
  for session in &to_kill {
435
+ let targets = vec![session.as_str().to_string()];
436
+ if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
437
+ event_log,
438
+ transport,
439
+ "shutdown.selected_session",
440
+ crate::kill_audit::KILL_SESSION,
441
+ &targets,
442
+ ) {
443
+ error
444
+ .get_or_insert_with(|| format!("pre-kill audit failed: {audit_error}"));
445
+ continue;
446
+ }
409
447
  if let Err(err) = transport.kill_session(session) {
410
448
  if !tmux_absent_error(&err.to_string()) {
411
449
  error.get_or_insert_with(|| err.to_string());
@@ -421,6 +459,59 @@ pub mod lifecycle_port {
421
459
  }
422
460
  }
423
461
 
462
+ #[cfg(test)]
463
+ mod kill_audit_tests {
464
+ use std::collections::BTreeMap;
465
+
466
+ use super::*;
467
+ use crate::transport::test_support::OfflineTransport;
468
+ use crate::transport::{PaneId, PaneInfo, SessionName};
469
+
470
+ #[test]
471
+ fn audit_write_failure_skips_destructive_kill() {
472
+ let root =
473
+ std::env::temp_dir().join(format!("ta-kill-audit-fail-{}", std::process::id()));
474
+ let blocked_parent = root.join("not-a-directory");
475
+ std::fs::create_dir_all(&root).unwrap();
476
+ std::fs::write(&blocked_parent, b"file").unwrap();
477
+ let event_log = crate::event_log::EventLog::at(blocked_parent.join("events.jsonl"));
478
+ let session = SessionName::new("team-audit-fail");
479
+ let transport = OfflineTransport::new()
480
+ .with_session_present(true)
481
+ .with_targets(vec![PaneInfo {
482
+ pane_id: PaneId::new("%1"),
483
+ session: session.clone(),
484
+ window_index: Some(0),
485
+ window_name: None,
486
+ pane_index: Some(0),
487
+ tty: None,
488
+ current_command: None,
489
+ current_path: None,
490
+ active: true,
491
+ pane_pid: None,
492
+ leader_env: BTreeMap::new(),
493
+ }]);
494
+ let cleanup = bare_shutdown_socket_cleanup(
495
+ &transport,
496
+ &json!({
497
+ "is_external_leader": true,
498
+ "tmux_socket_source": "leader_env",
499
+ }),
500
+ &event_log,
501
+ );
502
+
503
+ assert!(
504
+ !transport.calls().contains(&"kill_session"),
505
+ "an unaudited destructive call must not execute"
506
+ );
507
+ assert!(cleanup
508
+ .error
509
+ .as_deref()
510
+ .is_some_and(|error| error.contains("pre-kill audit failed")));
511
+ let _ = std::fs::remove_dir_all(root);
512
+ }
513
+ }
514
+
424
515
  fn state_uses_external_leader(state: &Value) -> bool {
425
516
  crate::state::projection::state_is_external_leader(state)
426
517
  }
@@ -501,6 +592,17 @@ pub mod lifecycle_port {
501
592
  );
502
593
  continue;
503
594
  }
595
+ let targets = vec![session.as_str().to_string()];
596
+ if let Err(audit_error) = crate::kill_audit::pre_kill_audit(
597
+ event_log,
598
+ transport,
599
+ "shutdown.managed_worker_session",
600
+ crate::kill_audit::KILL_SESSION,
601
+ &targets,
602
+ ) {
603
+ error.get_or_insert_with(|| format!("pre-kill audit failed: {audit_error}"));
604
+ continue;
605
+ }
504
606
  if let Err(err) = transport.kill_session(session) {
505
607
  if !tmux_absent_error(&err.to_string()) {
506
608
  error.get_or_insert_with(|| err.to_string());
@@ -598,6 +700,16 @@ pub mod lifecycle_port {
598
700
  );
599
701
  return cleanup;
600
702
  }
703
+ if let Err(error) = crate::kill_audit::pre_kill_audit(
704
+ event_log,
705
+ transport,
706
+ "shutdown.empty_owned_endpoint",
707
+ crate::kill_audit::KILL_SERVER,
708
+ &[],
709
+ ) {
710
+ cleanup.error = Some(format!("pre-kill audit failed: {error}"));
711
+ return cleanup;
712
+ }
601
713
  if let Err(error) = transport.kill_server() {
602
714
  cleanup.error = Some(error.to_string());
603
715
  }
@@ -748,11 +860,24 @@ pub mod lifecycle_port {
748
860
  let _ =
749
861
  crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
750
862
  } else {
751
- push_unique_session(&mut killed_sessions, session.clone());
752
- if let Err(error) = transport.kill_session(session) {
753
- if !tmux_absent_error(&error.to_string()) {
863
+ let targets = vec![session.as_str().to_string()];
864
+ let event_log = crate::event_log::EventLog::new(&run_workspace);
865
+ if let Err(error) = crate::kill_audit::pre_kill_audit(
866
+ &event_log,
867
+ transport,
868
+ "shutdown.team_session",
869
+ crate::kill_audit::KILL_SESSION,
870
+ &targets,
871
+ ) {
872
+ kill_error = Some(format!("pre-kill audit failed: {error}"));
873
+ } else if let Err(error) = transport.kill_session(session) {
874
+ if tmux_absent_error(&error.to_string()) {
875
+ push_unique_session(&mut killed_sessions, session.clone());
876
+ } else {
754
877
  kill_error = Some(error.to_string());
755
878
  }
879
+ } else {
880
+ push_unique_session(&mut killed_sessions, session.clone());
756
881
  }
757
882
  let _ =
758
883
  crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
@@ -2357,10 +2482,36 @@ pub mod lifecycle_port {
2357
2482
  "ok": true,
2358
2483
  "source_agent_id": report.source_agent_id.as_str(),
2359
2484
  "new_agent_id": report.new_agent_id.as_str(),
2485
+ "session_id": report.session_id.as_ref().map(|session| session.as_str()),
2486
+ "new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
2360
2487
  })),
2361
2488
  Err(e) => Ok(error_value(e)),
2362
2489
  }
2363
2490
  }
2491
+ pub fn clone_agent(
2492
+ workspace: &Path,
2493
+ source_agent: &str,
2494
+ as_agent_id: &str,
2495
+ label: Option<&str>,
2496
+ open_display: bool,
2497
+ team: Option<&str>,
2498
+ ) -> Result<Value, CliError> {
2499
+ let source = crate::model::ids::AgentId::new(source_agent);
2500
+ let dest = crate::model::ids::AgentId::new(as_agent_id);
2501
+ match crate::lifecycle::clone_agent(workspace, &source, &dest, label, open_display, team) {
2502
+ Ok(report) => Ok(json!({
2503
+ "ok": true,
2504
+ "status": "cloned",
2505
+ "source_agent_id": report.source_agent_id.as_str(),
2506
+ "new_agent_id": report.new_agent_id.as_str(),
2507
+ "session_id": report.session_id.as_ref().map(|session| session.as_str()),
2508
+ "new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
2509
+ "backing_path": report.backing_path.as_ref().map(|path| path.to_string_lossy()),
2510
+ "backing_state": report.backing_state,
2511
+ })),
2512
+ Err(error) => Ok(error_value(error)),
2513
+ }
2514
+ }
2364
2515
  /// `runtime.remove_agent`(`cmd_remove_agent`;`--from-spec` 须配 `--confirm`)。
2365
2516
  pub fn remove_agent(
2366
2517
  workspace: &Path,
@@ -939,103 +939,71 @@ fn resolve_leader(
939
939
  );
940
940
  return Err(err);
941
941
  }
942
- if crate::codex_app_server::receiver_is_app_server(receiver) {
943
- return resolve_app_server_leader(
944
- sender_workspace,
945
- target_workspace,
946
- team,
947
- parsed,
948
- receiver,
949
- );
950
- }
951
- let pane_id = string_field(receiver, "pane_id")
952
- .filter(|pane| !pane.is_empty())
953
- .ok_or_else(|| {
954
- let mut err = leader_not_attached_third_party(
955
- target_workspace,
956
- team,
957
- None,
958
- None,
959
- None,
960
- transport.tmux_endpoint(),
961
- );
962
- err.candidates =
963
- leader_advisory_candidates(state, team, transport, "state_recorded_socket");
964
- err
965
- })?;
966
942
  let session = string_field(receiver, "session_name");
967
943
  let window = string_field(receiver, "window_name");
968
944
  let socket = string_field(receiver, "tmux_socket")
969
945
  .map(str::to_string)
970
946
  .or_else(|| transport.tmux_endpoint());
971
- let targets = list_targets(transport)?;
972
- let matches = targets
973
- .iter()
974
- .filter(|target| {
975
- target.pane_id.as_str() == pane_id
976
- && session.is_none_or(|expected| target.session.as_str() == expected)
977
- && window.is_none_or(|expected| {
978
- target
979
- .window_name
980
- .as_ref()
981
- .is_some_and(|name| name.as_str() == expected)
982
- })
983
- })
984
- .collect::<Vec<_>>();
985
- let stale_pane_seen = targets
986
- .iter()
987
- .any(|target| target.pane_id.as_str() == pane_id);
988
- match matches.len() {
989
- 1 => Ok(ResolvedNamedAddress {
947
+ match crate::messaging::resolve_live_leader_channel(
948
+ target_workspace,
949
+ receiver,
950
+ transport,
951
+ ) {
952
+ crate::messaging::LeaderChannelResolution::Live(
953
+ crate::messaging::LiveLeaderChannel::DirectTmux(channel),
954
+ ) => Ok(ResolvedNamedAddress {
990
955
  raw_name: parsed.display_name(),
991
956
  target_kind: NamedTargetKind::Leader,
992
957
  sender_workspace: sender_workspace.to_path_buf(),
993
958
  target_workspace: target_workspace.to_path_buf(),
994
959
  team_key: Some(team.to_string()),
995
960
  agent_id: None,
996
- pane_id: pane_id.to_string(),
961
+ pane_id: channel.pane_id.clone(),
997
962
  session_name: session.map(str::to_string),
998
963
  window_name: window.map(str::to_string),
999
964
  tmux_endpoint: socket,
1000
965
  transport_kind: Some("direct_tmux".to_string()),
1001
966
  app_server: None,
1002
- state_pane_id: Some(pane_id.to_string()),
967
+ state_pane_id: Some(channel.pane_id),
1003
968
  state_pane_stale: false,
1004
969
  agent_status: None,
1005
- warning: None,
970
+ warning: (!channel.metadata_drift.is_empty()).then(|| {
971
+ format!(
972
+ "leader_channel_metadata_drift:{}",
973
+ channel.metadata_drift.join(",")
974
+ )
975
+ }),
1006
976
  }),
1007
- 0 => {
977
+ crate::messaging::LeaderChannelResolution::Live(
978
+ crate::messaging::LiveLeaderChannel::CodexAppServer(_),
979
+ ) => resolve_app_server_leader(sender_workspace, target_workspace, team, parsed, receiver),
980
+ crate::messaging::LeaderChannelResolution::Unbound(reason) => {
981
+ let pane_id = string_field(receiver, "pane_id");
1008
982
  let mut err = leader_not_attached_third_party(
1009
983
  target_workspace,
1010
984
  team,
1011
- Some(pane_id),
985
+ pane_id,
1012
986
  session,
1013
987
  window,
1014
988
  socket,
1015
989
  );
1016
- if stale_pane_seen {
1017
- err.log = format!("{} stale_tuple_mismatch=true", err.log);
1018
- }
990
+ err.log = format!("{} channel_unbound={reason:?}", err.log);
1019
991
  err.candidates =
1020
992
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
1021
993
  Err(err)
1022
994
  }
1023
- _ => Err(name_ambiguous(
1024
- "multiple live panes matched leader_receiver pane id",
1025
- matches
1026
- .into_iter()
1027
- .map(|pane| {
1028
- json!({
1029
- "name": format!("{team}/leader"),
1030
- "workspace": target_workspace.display().to_string(),
1031
- "team_key": team,
1032
- "pane_id": pane.pane_id.as_str(),
1033
- "session": pane.session.as_str(),
1034
- "window": pane.window_name.as_ref().map(|w| w.as_str()),
1035
- })
1036
- })
1037
- .collect(),
1038
- )),
995
+ crate::messaging::LeaderChannelResolution::ProbeFailed(error) => {
996
+ let mut err = leader_not_attached_third_party(
997
+ target_workspace,
998
+ team,
999
+ string_field(receiver, "pane_id"),
1000
+ session,
1001
+ window,
1002
+ socket,
1003
+ );
1004
+ err.log = format!("{} channel_probe_failed={error}", err.log);
1005
+ Err(err)
1006
+ }
1039
1007
  }
1040
1008
  }
1041
1009
 
@@ -55,6 +55,7 @@ pub(crate) enum DispatchKind {
55
55
  StopAgent,
56
56
  ResetAgent,
57
57
  AddAgent,
58
+ CloneAgent,
58
59
  ForkAgent,
59
60
  RemoveAgent,
60
61
  StuckList,
@@ -98,6 +99,7 @@ pub(crate) const ALL_DISPATCH_KINDS: &[DispatchKind] = &[
98
99
  DispatchKind::StopAgent,
99
100
  DispatchKind::ResetAgent,
100
101
  DispatchKind::AddAgent,
102
+ DispatchKind::CloneAgent,
101
103
  DispatchKind::ForkAgent,
102
104
  DispatchKind::RemoveAgent,
103
105
  DispatchKind::StuckList,
@@ -183,6 +185,7 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
183
185
  CommandSpec { name: "attach-app-server-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::AttachAppServerLeader), summary: "bind an app-server leader receiver", usage: "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
184
186
  CommandSpec { name: "remove-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::RemoveAgent), summary: "remove a worker", usage: "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
185
187
  CommandSpec { name: "fork-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ForkAgent), summary: "fork a worker", usage: "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
188
+ CommandSpec { name: "clone-agent", tier: CommandTier::Secondary, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::CloneAgent), summary: "clone a worker role into a fresh session", usage: "usage: team-agent clone-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
186
189
  CommandSpec { name: "allow-peer-talk", tier: CommandTier::Secondary, category: CommandCategory::Setup, kind: CommandKind::Dispatch(DispatchKind::AllowPeerTalk), summary: "allow direct worker peer talk", usage: "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: Some(GovernanceNote { decision: "secondary", reason: "capability/config surface" }) },
187
190
  CommandSpec { name: "approvals", tier: CommandTier::Secondary, category: CommandCategory::Observe, kind: CommandKind::Dispatch(DispatchKind::Approvals), summary: "inspect provider approval state", usage: "usage: team-agent approvals [AGENT] [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: Some(GovernanceNote { decision: "secondary", reason: "approval diagnostics" }) },
188
191
  CommandSpec { name: "profile", tier: CommandTier::Secondary, category: CommandCategory::Setup, kind: CommandKind::Dispatch(DispatchKind::Profile), summary: "manage provider profiles", usage: "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--json]", default_help: false, command_help: true, suggestion_index: false, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: Some(GovernanceNote { decision: "secondary", reason: "provider/profile setup" }) },
@@ -400,6 +400,7 @@ fn ux_wait_ready_does_not_report_ready_true_without_ready_runtime_state() {
400
400
  crate::state::persist::save_runtime_state(
401
401
  &ws,
402
402
  &json!({
403
+ "active_team_key": "wait-ready",
403
404
  "agents": {"w1": {"status": "starting"}},
404
405
  "tasks": [{"id": "t1", "assignee": "w1", "status": "pending"}],
405
406
  "leader_receiver": {"status": "attached"},
@@ -514,6 +514,17 @@ pub struct ForkAgentArgs {
514
514
  pub json: bool,
515
515
  }
516
516
 
517
+ #[derive(Debug, Clone, PartialEq, Eq)]
518
+ pub struct CloneAgentArgs {
519
+ pub source_agent: String,
520
+ pub workspace: PathBuf,
521
+ pub team: Option<String>,
522
+ pub as_agent: String,
523
+ pub label: Option<String>,
524
+ pub no_display: bool,
525
+ pub json: bool,
526
+ }
527
+
517
528
  /// `remove-agent`(`parser.py:414`)。`--from-spec` 须配 `--confirm`。
518
529
  #[derive(Debug, Clone, PartialEq, Eq)]
519
530
  pub struct RemoveAgentArgs {
@@ -48,6 +48,14 @@ impl OrphanScanScope {
48
48
  canonical_current_workspace: canonicalize_workspace(workspace),
49
49
  }
50
50
  }
51
+
52
+ fn workspace(&self) -> &Path {
53
+ match self {
54
+ Self::CurrentWorkspace {
55
+ canonical_current_workspace,
56
+ } => canonical_current_workspace,
57
+ }
58
+ }
51
59
  }
52
60
 
53
61
  fn canonicalize_workspace(workspace: &Path) -> PathBuf {
@@ -262,7 +270,7 @@ fn cleanup_report(report: ScanReport, scope: &OrphanScanScope) -> CleanupReport
262
270
  }
263
271
  continue;
264
272
  }
265
- if kill_tmux_session(orphan) {
273
+ if kill_tmux_session(orphan, scope.workspace()) {
266
274
  killed.push(orphan_value(orphan, "killed"));
267
275
  } else {
268
276
  failed.push(orphan_value(orphan, "failed"));
@@ -790,14 +798,27 @@ fn orphan_value(orphan: &OrphanRecord, action: &str) -> Value {
790
798
  value
791
799
  }
792
800
 
793
- fn kill_tmux_session(orphan: &OrphanRecord) -> bool {
801
+ fn kill_tmux_session(orphan: &OrphanRecord, audit_workspace: &Path) -> bool {
794
802
  let (Some(socket), Some(session)) = (&orphan.tmux_socket, &orphan.session) else {
795
803
  return false;
796
804
  };
797
805
  // Phase 1d Batch 6: factory tmux-socket-name helper. Explicit
798
806
  // tmux-only kill; conpty shim orphans are NOT routed here (design
799
807
  // §Batch 6).
800
- crate::transport_factory::tmux_socket_name_transport(socket)
808
+ let transport = crate::transport_factory::tmux_socket_name_transport(socket);
809
+ let targets = vec![session.clone()];
810
+ if crate::kill_audit::pre_kill_audit(
811
+ &crate::event_log::EventLog::new(audit_workspace),
812
+ &transport,
813
+ "diagnose.orphans.tmux_session",
814
+ crate::kill_audit::KILL_SESSION,
815
+ &targets,
816
+ )
817
+ .is_err()
818
+ {
819
+ return false;
820
+ }
821
+ transport
801
822
  .kill_session(&SessionName::new(session.clone()))
802
823
  .is_ok()
803
824
  }
@@ -0,0 +1,67 @@
1
+ //! Durable, fail-closed observation immediately before destructive transport calls.
2
+
3
+ use std::path::Path;
4
+
5
+ use serde_json::json;
6
+
7
+ use crate::event_log::{EventLog, EventLogError};
8
+ use crate::transport::{BackendKind, Transport};
9
+
10
+ pub(crate) const KILL_SERVER: &str = "kill-server";
11
+ pub(crate) const KILL_SESSION: &str = "kill-session";
12
+
13
+ pub(crate) fn pre_kill_audit(
14
+ event_log: &EventLog,
15
+ transport: &dyn Transport,
16
+ caller: &str,
17
+ action: &str,
18
+ targets: &[String],
19
+ ) -> Result<(), EventLogError> {
20
+ let endpoint = transport.tmux_endpoint();
21
+ let argv = destructive_argv(transport.kind(), endpoint.as_deref(), action, targets);
22
+ event_log.write(
23
+ "transport.pre_kill_audit",
24
+ json!({
25
+ "phase": "pre_call",
26
+ "caller": caller,
27
+ "caller_pid": std::process::id(),
28
+ "action": action,
29
+ "argv": argv,
30
+ "endpoint": endpoint,
31
+ "targets": targets,
32
+ }),
33
+ )?;
34
+ Ok(())
35
+ }
36
+
37
+ fn destructive_argv(
38
+ kind: BackendKind,
39
+ endpoint: Option<&str>,
40
+ action: &str,
41
+ targets: &[String],
42
+ ) -> Vec<String> {
43
+ let mut argv = if kind == BackendKind::Tmux {
44
+ let mut argv = vec!["tmux".to_string()];
45
+ if let Some(endpoint) = endpoint.filter(|endpoint| !endpoint.is_empty()) {
46
+ if endpoint != "default" {
47
+ argv.push(if Path::new(endpoint).is_absolute() {
48
+ "-S".to_string()
49
+ } else {
50
+ "-L".to_string()
51
+ });
52
+ argv.push(endpoint.to_string());
53
+ }
54
+ }
55
+ argv
56
+ } else {
57
+ vec![format!("{kind:?}").to_ascii_lowercase()]
58
+ };
59
+ argv.push(action.to_string());
60
+ if action == KILL_SESSION {
61
+ for target in targets {
62
+ argv.push("-t".to_string());
63
+ argv.push(target.clone());
64
+ }
65
+ }
66
+ argv
67
+ }