@team-agent/installer 0.5.52 → 0.5.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +18 -2
  4. package/crates/team-agent/src/cli/emit.rs +111 -3
  5. package/crates/team-agent/src/cli/mod.rs +155 -4
  6. package/crates/team-agent/src/cli/send/persist.rs +1 -0
  7. package/crates/team-agent/src/cli/send/presentation.rs +1 -0
  8. package/crates/team-agent/src/cli/send.rs +1 -0
  9. package/crates/team-agent/src/cli/spec.rs +4 -1
  10. package/crates/team-agent/src/cli/tests/lane_c.rs +3 -3
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +1 -0
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +12 -0
  15. package/crates/team-agent/src/coordinator/tests/basics.rs +4 -4
  16. package/crates/team-agent/src/db/message_store.rs +31 -2
  17. package/crates/team-agent/src/db/migration.rs +7 -6
  18. package/crates/team-agent/src/db/schema.rs +18 -5
  19. package/crates/team-agent/src/diagnose/orphans.rs +24 -3
  20. package/crates/team-agent/src/kill_audit.rs +67 -0
  21. package/crates/team-agent/src/leader/lease.rs +324 -57
  22. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -0
  23. package/crates/team-agent/src/leader/rediscover.rs +6 -0
  24. package/crates/team-agent/src/leader/start.rs +144 -23
  25. package/crates/team-agent/src/leader/tests/idle.rs +3 -0
  26. package/crates/team-agent/src/leader/types.rs +6 -0
  27. package/crates/team-agent/src/lib.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +5 -0
  30. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +4 -0
  31. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +106 -0
  32. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +216 -208
  33. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +36 -0
  34. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +238 -0
  35. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +101 -5
  36. package/crates/team-agent/src/lifecycle/launch/role_source.rs +170 -0
  37. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +1 -1
  38. package/crates/team-agent/src/lifecycle/launch.rs +14 -2
  39. package/crates/team-agent/src/lifecycle/restart/agent.rs +1 -1
  40. package/crates/team-agent/src/lifecycle/restart/common.rs +12 -0
  41. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +13 -3
  42. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +110 -12
  43. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +8 -2
  44. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +1 -0
  45. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +3 -1
  46. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  47. package/crates/team-agent/src/lifecycle/types.rs +17 -0
  48. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +40 -5
  49. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/normalize.rs +8 -0
  51. package/crates/team-agent/src/mcp_server/tests/wire.rs +243 -167
  52. package/crates/team-agent/src/mcp_server/tools.rs +89 -4
  53. package/crates/team-agent/src/mcp_server/types.rs +7 -0
  54. package/crates/team-agent/src/mcp_server/wire.rs +65 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +2 -0
  56. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  57. package/crates/team-agent/src/messaging/leader_channel.rs +32 -5
  58. package/crates/team-agent/src/messaging/leader_receiver.rs +100 -21
  59. package/crates/team-agent/src/messaging/mod.rs +2 -1
  60. package/crates/team-agent/src/messaging/persist.rs +68 -2
  61. package/crates/team-agent/src/messaging/presentation.rs +307 -0
  62. package/crates/team-agent/src/messaging/results.rs +130 -3
  63. package/crates/team-agent/src/messaging/selftest.rs +1 -0
  64. package/crates/team-agent/src/messaging/send.rs +54 -2
  65. package/crates/team-agent/src/messaging/tests/runtime.rs +85 -24
  66. package/crates/team-agent/src/messaging/types.rs +2 -0
  67. package/crates/team-agent/src/messaging/watchers.rs +1 -0
  68. package/crates/team-agent/src/provider/adapter.rs +54 -13
  69. package/crates/team-agent/src/provider/adapters/claude_fork.rs +122 -0
  70. package/crates/team-agent/src/provider/adapters/copilot_fork.rs +306 -0
  71. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  72. package/crates/team-agent/src/provider/session/capture.rs +395 -52
  73. package/crates/team-agent/src/provider/session/context_fork.rs +499 -0
  74. package/crates/team-agent/src/provider/session/mod.rs +6 -0
  75. package/crates/team-agent/src/provider/session_scan/claude.rs +1 -1
  76. package/crates/team-agent/src/provider/session_scan/codex.rs +144 -27
  77. package/crates/team-agent/src/provider/session_scan/common/tests.rs +112 -0
  78. package/crates/team-agent/src/provider/session_scan/common.rs +53 -92
  79. package/crates/team-agent/src/provider/session_scan/copilot.rs +47 -3
  80. package/crates/team-agent/src/provider/session_scan.rs +3 -3
  81. package/crates/team-agent/src/provider/tests/copilot_fork.rs +191 -0
  82. package/crates/team-agent/src/provider/tests.rs +1 -0
  83. package/crates/team-agent/src/tmux_backend/tests.rs +51 -16
  84. package/crates/team-agent/src/tmux_backend.rs +29 -2
  85. package/package.json +4 -4
  86. package/schemas/result-envelope.schema.json +10 -0
  87. package/skills/team-agent/SKILL.md +11 -3
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.54"
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.54"
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),
@@ -316,8 +317,11 @@ fn command_help(command: Option<&str>) -> String {
316
317
  Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
317
318
  Some("send") => concat!(
318
319
  "usage: team-agent send TO MESSAGE... ",
319
- "[--workspace WORKSPACE] [--team TEAM] [--json]\n\n",
320
- "TO is a logical recipient; send returns after the message is persisted."
320
+ "[--workspace WORKSPACE] [--team TEAM] ",
321
+ "[--presentation-sink leader|casefile|silent --message-class CLASS [--case-id CASE]] ",
322
+ "[--json]\n\n",
323
+ "TO is a logical recipient; send returns after the message is persisted. ",
324
+ "Presentation changes live display only; every sink remains durable."
321
325
  )
322
326
  .to_string(),
323
327
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
@@ -330,6 +334,7 @@ fn command_help(command: Option<&str>) -> String {
330
334
  Some("start-agent") => "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--no-display] [--json]".to_string(),
331
335
  Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
332
336
  Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
337
+ Some("clone-agent") => "usage: team-agent clone-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
333
338
  Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--no-display] [--json]".to_string(),
334
339
  Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
335
340
  // 0.5.26 (§7.6): removed from help; dispatch was never wired.
@@ -773,6 +778,9 @@ struct ParsedArgs {
773
778
  socket: Option<String>,
774
779
  thread_id: Option<String>,
775
780
  message_id: Option<String>,
781
+ presentation_sink: Option<String>,
782
+ message_class: Option<String>,
783
+ case_id: Option<String>,
776
784
  content: Option<String>,
777
785
  primary_error: Option<String>,
778
786
  agent_id: Option<String>,
@@ -863,6 +871,9 @@ fn parse_args(args: &[String]) -> ParsedArgs {
863
871
  "--socket" => parsed.socket = next_arg(args, &mut i),
864
872
  "--thread-id" => parsed.thread_id = next_arg(args, &mut i),
865
873
  "--message-id" => parsed.message_id = next_arg(args, &mut i),
874
+ "--presentation-sink" => parsed.presentation_sink = next_arg(args, &mut i),
875
+ "--message-class" => parsed.message_class = next_arg(args, &mut i),
876
+ "--case-id" => parsed.case_id = next_arg(args, &mut i),
866
877
  "--content" => parsed.content = next_arg(args, &mut i),
867
878
  "--primary-error" => parsed.primary_error = next_arg(args, &mut i),
868
879
  "--result-json" => parsed.result_json = next_arg(args, &mut i),
@@ -879,6 +890,17 @@ fn parse_args(args: &[String]) -> ParsedArgs {
879
890
  other if other.starts_with("--to-leader=") => {
880
891
  parsed.to_leader = Some(other.trim_start_matches("--to-leader=").to_string());
881
892
  }
893
+ other if other.starts_with("--presentation-sink=") => {
894
+ parsed.presentation_sink =
895
+ Some(other.trim_start_matches("--presentation-sink=").to_string());
896
+ }
897
+ other if other.starts_with("--message-class=") => {
898
+ parsed.message_class =
899
+ Some(other.trim_start_matches("--message-class=").to_string());
900
+ }
901
+ other if other.starts_with("--case-id=") => {
902
+ parsed.case_id = Some(other.trim_start_matches("--case-id=").to_string());
903
+ }
882
904
  other if other.starts_with("--provider=") => {
883
905
  parsed.provider = Some(other.trim_start_matches("--provider=").to_string());
884
906
  }
@@ -1027,6 +1049,23 @@ fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
1027
1049
  };
1028
1050
  let message_start = usize::from(target.is_some());
1029
1051
  let workspace = workspace(&parsed, cwd);
1052
+ let presentation_value = if parsed.presentation_sink.is_some()
1053
+ || parsed.message_class.is_some()
1054
+ || parsed.case_id.is_some()
1055
+ {
1056
+ Some(serde_json::json!({
1057
+ "sink": parsed.presentation_sink.clone(),
1058
+ "class": parsed.message_class.clone(),
1059
+ "case_id": parsed.case_id.clone(),
1060
+ }))
1061
+ } else {
1062
+ None
1063
+ };
1064
+ let (presentation, presentation_error) =
1065
+ crate::messaging::presentation::normalize_presentation(presentation_value.as_ref());
1066
+ if let Some(error) = presentation_error {
1067
+ return Err(CliError::Usage(format!("invalid presentation: {error}")));
1068
+ }
1030
1069
  Ok(SendArgs {
1031
1070
  target,
1032
1071
  message: parsed
@@ -1047,6 +1086,7 @@ fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
1047
1086
  confirm_human: false,
1048
1087
  json: parsed.json,
1049
1088
  message_id: None,
1089
+ presentation,
1050
1090
  pane: parsed.pane.clone(),
1051
1091
  to_name: parsed.to_name.clone(),
1052
1092
  to_leader: parsed.to_leader.clone(),
@@ -1109,11 +1149,22 @@ fn validate_send_flags(args: &[String]) -> Result<(), CliError> {
1109
1149
  "--timeout",
1110
1150
  "--confirm-human",
1111
1151
  "--message-id",
1152
+ "--presentation-sink",
1153
+ "--message-class",
1154
+ "--case-id",
1112
1155
  "--json",
1113
1156
  "-h",
1114
1157
  "--help",
1115
1158
  ];
1116
- const ALLOWED_PREFIXES: &[&str] = &["--team=", "--pane=", "--to-name=", "--to-leader="];
1159
+ const ALLOWED_PREFIXES: &[&str] = &[
1160
+ "--team=",
1161
+ "--pane=",
1162
+ "--to-name=",
1163
+ "--to-leader=",
1164
+ "--presentation-sink=",
1165
+ "--message-class=",
1166
+ "--case-id=",
1167
+ ];
1117
1168
  if let Some(flag) = args.iter().find(|arg| {
1118
1169
  arg.starts_with('-')
1119
1170
  && !ALLOWED.contains(&arg.as_str())
@@ -1366,6 +1417,21 @@ fn fork_agent_args(args: &[String], cwd: &Path) -> Result<ForkAgentArgs, CliErro
1366
1417
  })
1367
1418
  }
1368
1419
 
1420
+ fn clone_agent_args(args: &[String], cwd: &Path) -> Result<CloneAgentArgs, CliError> {
1421
+ let parsed = parse_args(args);
1422
+ Ok(CloneAgentArgs {
1423
+ source_agent: required_pos(&parsed, 0, "source_agent")?,
1424
+ workspace: workspace(&parsed, cwd),
1425
+ team: parsed.team,
1426
+ as_agent: parsed
1427
+ .as_agent
1428
+ .ok_or_else(|| CliError::Usage("missing --as".to_string()))?,
1429
+ label: parsed.label,
1430
+ no_display: parsed.no_display,
1431
+ json: parsed.json,
1432
+ })
1433
+ }
1434
+
1369
1435
  fn remove_agent_args(args: &[String], cwd: &Path) -> Result<RemoveAgentArgs, CliError> {
1370
1436
  let parsed = parse_args(args);
1371
1437
  let workspace = workspace(&parsed, cwd);
@@ -1998,6 +2064,48 @@ mod tests {
1998
2064
  let _ = std::fs::remove_dir_all(&cwd);
1999
2065
  }
2000
2066
 
2067
+ #[test]
2068
+ fn send_presentation_flags_map_to_the_typed_request() {
2069
+ let cwd = tmp_workspace();
2070
+ let args = send_args(
2071
+ &cli_argv(&[
2072
+ "leader",
2073
+ "progress",
2074
+ "--presentation-sink",
2075
+ "casefile",
2076
+ "--message-class",
2077
+ "progress",
2078
+ "--case-id",
2079
+ "case-9",
2080
+ ]),
2081
+ &cwd,
2082
+ )
2083
+ .unwrap();
2084
+ assert_eq!(
2085
+ args.presentation.sink,
2086
+ crate::messaging::presentation::PresentationSink::Casefile
2087
+ );
2088
+ assert_eq!(
2089
+ args.presentation.class,
2090
+ crate::messaging::presentation::PresentationClass::Progress
2091
+ );
2092
+ assert_eq!(args.presentation.case_id.as_deref(), Some("case-9"));
2093
+ }
2094
+
2095
+ #[test]
2096
+ fn send_presentation_flags_fail_closed_when_incomplete() {
2097
+ let cwd = tmp_workspace();
2098
+ let error = send_args(
2099
+ &cli_argv(&["leader", "progress", "--presentation-sink", "casefile"]),
2100
+ &cwd,
2101
+ )
2102
+ .unwrap_err();
2103
+ assert!(matches!(
2104
+ error,
2105
+ CliError::Usage(message) if message == "invalid presentation: missing_class"
2106
+ ));
2107
+ }
2108
+
2001
2109
  #[test]
2002
2110
  fn send_to_name_positionals_are_message_not_target() {
2003
2111
  let cwd = tmp_workspace();
@@ -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,
@@ -25,6 +25,7 @@ pub fn send_options_from_args(args: &SendArgs) -> SendOptions {
25
25
  watch_result: args.watch_result,
26
26
  team: args.team.as_ref().map(|s| TeamKey::new(s.clone())),
27
27
  message_id: args.message_id.clone(),
28
+ presentation: args.presentation.clone(),
28
29
  ..SendOptions::default()
29
30
  }
30
31
  }
@@ -285,6 +285,7 @@ pub(super) fn first_target(target: &MessageTarget) -> String {
285
285
  pub(super) fn delivery_status_wire(status: DeliveryStatus) -> &'static str {
286
286
  match status {
287
287
  DeliveryStatus::Delivered => "delivered",
288
+ DeliveryStatus::StoredOnly => "stored_only",
288
289
  DeliveryStatus::Failed => "failed",
289
290
  DeliveryStatus::Queued => "queued",
290
291
  DeliveryStatus::Blocked => "blocked",
@@ -169,6 +169,7 @@ pub fn send_to_canonical_leader_target(
169
169
  confirm_human: false,
170
170
  json: true,
171
171
  message_id: None,
172
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
172
173
  pane: None,
173
174
  to_name: None,
174
175
  to_leader: None,
@@ -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,
@@ -153,7 +155,7 @@ pub(crate) struct CommandSpec {
153
155
  #[rustfmt::skip]
154
156
  pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
155
157
  CommandSpec { name: "quick-start", tier: CommandTier::Core, category: CommandCategory::Start, kind: CommandKind::Dispatch(DispatchKind::QuickStart), summary: "start or attach a team from TEAM.md", usage: "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
156
- CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for a logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
158
+ CommandSpec { name: "send", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Send), summary: "persist a message for a logical recipient", usage: "usage: team-agent send TO MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--presentation-sink leader|casefile|silent --message-class CLASS [--case-id CASE]] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: Some("next compatibility release"), action: Some("use positional logical TO and the returned message id"), governance: None },
157
159
  CommandSpec { name: "status", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Status), summary: "show current team status", usage: "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
158
160
  CommandSpec { name: "collect", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Collect), summary: "collect reported results", usage: "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, alias_of: None, sunset: None, action: None, governance: None },
159
161
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, alias_of: None, sunset: None, action: None, governance: None },
@@ -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" }) },
@@ -361,7 +361,7 @@ fn cleanup_orphans_dryrun_golden_envelope() {
361
361
  // ── fix_schema: golden fix_schema_layout diagnosis envelope (schema_migration.py:258) ─────────────
362
362
  // {ok,status,db_path,schema_version,user_version,layout_diffs,recommended_action,would_backup_path,
363
363
  // fixed,rebuilds}. RUST mod.rs:538-540 stub {ok,fixed:false}. db_path/would_backup_path are
364
- // path/clock-derived; lock the deterministic fields. SCHEMA_VERSION==3. RED. ─────────────────────
364
+ // path/clock-derived; lock the deterministic fields against the canonical SCHEMA_VERSION. ────────
365
365
  #[test]
366
366
  fn fix_schema_golden_layout_diagnosis_envelope() {
367
367
  let ws = tmp_workspace();
@@ -370,8 +370,8 @@ fn fix_schema_golden_layout_diagnosis_envelope() {
370
370
  assert_eq!(v["status"], json!("missing"), "missing db: status missing");
371
371
  assert_eq!(
372
372
  v["schema_version"],
373
- json!(3),
374
- "golden schema_version == SCHEMA_VERSION (3)"
373
+ json!(crate::db::schema::SCHEMA_VERSION),
374
+ "golden schema_version == canonical SCHEMA_VERSION"
375
375
  );
376
376
  assert_eq!(v["user_version"], json!(0), "missing db user_version == 0");
377
377
  assert_eq!(
@@ -49,6 +49,7 @@ fn cli_send_persists_real_message_row() {
49
49
  confirm_human: false,
50
50
  json: true,
51
51
  message_id: None,
52
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
52
53
  pane: None,
53
54
  to_name: None,
54
55
  to_leader: None,
@@ -157,6 +157,7 @@ fn named_send_args(
157
157
  confirm_human: false,
158
158
  json: true,
159
159
  message_id: None,
160
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
160
161
  pane: pane.map(str::to_string),
161
162
  to_name: to_name.map(str::to_string),
162
163
  to_leader: None,
@@ -468,6 +468,7 @@ fn send_args_fixture() -> SendArgs {
468
468
  confirm_human: false,
469
469
  json: false,
470
470
  message_id: None,
471
+ presentation: crate::messaging::presentation::PresentationRequest::default(),
471
472
  pane: None,
472
473
  to_name: None,
473
474
  to_leader: None,
@@ -323,6 +323,7 @@ pub struct SendArgs {
323
323
  /// When set, the store insert uses this id verbatim; a repeat with the same
324
324
  /// id returns a `Duplicate` refusal instead of creating a second row.
325
325
  pub message_id: Option<String>,
326
+ pub presentation: crate::messaging::presentation::PresentationRequest,
326
327
  /// Deprecated compatibility input. Public send refuses pane identity and
327
328
  /// requires a logical recipient so persistence always precedes delivery.
328
329
  pub pane: Option<String>,
@@ -514,6 +515,17 @@ pub struct ForkAgentArgs {
514
515
  pub json: bool,
515
516
  }
516
517
 
518
+ #[derive(Debug, Clone, PartialEq, Eq)]
519
+ pub struct CloneAgentArgs {
520
+ pub source_agent: String,
521
+ pub workspace: PathBuf,
522
+ pub team: Option<String>,
523
+ pub as_agent: String,
524
+ pub label: Option<String>,
525
+ pub no_display: bool,
526
+ pub json: bool,
527
+ }
528
+
517
529
  /// `remove-agent`(`parser.py:414`)。`--from-spec` 须配 `--confirm`。
518
530
  #[derive(Debug, Clone, PartialEq, Eq)]
519
531
  pub struct RemoveAgentArgs {
@@ -365,16 +365,16 @@ fn orphan_self_terminate_false_when_workspace_exists() {
365
365
 
366
366
  #[test]
367
367
  fn metadata_ok_requires_all_three_to_match() {
368
- // metadata.py:37-43 — pid protocol_version==2 schema_version==3.
369
- let good = meta(555, PROTOCOL_VERSION, GOLDEN_SCHEMA_VERSION);
368
+ // metadata.py:37-43 — pid, protocol version, and current message schema all match.
369
+ let good = meta(555, PROTOCOL_VERSION, crate::db::schema::SCHEMA_VERSION);
370
370
  assert!(coordinator_metadata_ok(Some(&good), Pid(555)));
371
371
  // pid 不符 → false。
372
372
  assert!(!coordinator_metadata_ok(Some(&good), Pid(999)));
373
373
  // protocol_version 不符(bump 触发 restart_incompatible)→ false。
374
- let bad_proto = meta(555, 1, GOLDEN_SCHEMA_VERSION);
374
+ let bad_proto = meta(555, 1, crate::db::schema::SCHEMA_VERSION);
375
375
  assert!(!coordinator_metadata_ok(Some(&bad_proto), Pid(555)));
376
376
  // schema_version 不符 → false(不可静默继续旧 schema 写库,card §89)。
377
- let bad_schema = meta(555, PROTOCOL_VERSION, 2);
377
+ let bad_schema = meta(555, PROTOCOL_VERSION, crate::db::schema::SCHEMA_VERSION - 1);
378
378
  assert!(!coordinator_metadata_ok(Some(&bad_schema), Pid(555)));
379
379
  }
380
380