@team-agent/installer 0.5.52 → 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 (62) 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/spec.rs +3 -0
  7. package/crates/team-agent/src/cli/types.rs +11 -0
  8. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  9. package/crates/team-agent/src/kill_audit.rs +67 -0
  10. package/crates/team-agent/src/leader/lease.rs +324 -57
  11. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  12. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  13. package/crates/team-agent/src/leader/start.rs +144 -23
  14. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  15. package/crates/team-agent/src/leader/types.rs +6 -0
  16. package/crates/team-agent/src/lib.rs +1 -0
  17. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  18. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  19. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  20. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  21. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  22. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  23. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  24. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  25. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  26. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  27. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  28. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  30. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  31. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  32. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  33. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  34. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  35. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  36. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  37. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  38. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  39. package/crates/team-agent/src/mcp_server/tools.rs +18 -4
  40. package/crates/team-agent/src/mcp_server/types.rs +3 -0
  41. package/crates/team-agent/src/mcp_server/wire.rs +30 -7
  42. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  43. package/crates/team-agent/src/messaging/leader_receiver.rs +68 -21
  44. package/crates/team-agent/src/messaging/tests/runtime.rs +34 -23
  45. package/crates/team-agent/src/provider/adapter.rs +54 -13
  46. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  47. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  48. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  49. package/crates/team-agent/src/provider/session/capture.rs +321 -52
  50. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  51. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  52. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  53. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  54. package/crates/team-agent/src/provider/session_scan/common.rs +39 -6
  55. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  56. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  57. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  58. package/crates/team-agent/src/provider/tests.rs +1 -0
  59. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  60. package/crates/team-agent/src/tmux_backend.rs +29 -2
  61. package/package.json +4 -4
  62. 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.52"
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.52"
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,
@@ -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" }) },
@@ -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
+ }