@team-agent/installer 0.5.105 → 0.5.107

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 (46) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +5 -3
  4. package/README.zh.md +4 -3
  5. package/crates/team-agent/src/cli/adapters.rs +15 -1
  6. package/crates/team-agent/src/cli/emit.rs +33 -13
  7. package/crates/team-agent/src/cli/mod.rs +148 -21
  8. package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
  9. package/crates/team-agent/src/cli/tests/base.rs +112 -225
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +4 -2
  11. package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
  12. package/crates/team-agent/src/compiler/tests.rs +46 -59
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +1 -14
  14. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +57 -173
  15. package/crates/team-agent/src/lifecycle/launch/pi_mcp_extension.js +226 -0
  16. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +0 -37
  17. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
  18. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -0
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
  20. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -9
  21. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
  22. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -66
  23. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -392
  24. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
  25. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
  26. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
  27. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
  28. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
  29. package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
  30. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
  31. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  32. package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
  33. package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
  34. package/crates/team-agent/src/model/name_similarity.rs +52 -67
  35. package/crates/team-agent/src/model/task_graph.rs +37 -124
  36. package/crates/team-agent/src/model/yaml/tests.rs +46 -165
  37. package/crates/team-agent/src/provider/tests/adapter.rs +0 -20
  38. package/crates/team-agent/src/provider/tests/classify.rs +106 -198
  39. package/crates/team-agent/src/state/projection.rs +15 -0
  40. package/crates/team-agent/src/tmux_backend/tests.rs +315 -501
  41. package/crates/team-agent/src/tmux_backend.rs +110 -0
  42. package/crates/team-agent/src/transport/test_support.rs +35 -0
  43. package/crates/team-agent/src/transport/tests/wire.rs +32 -58
  44. package/crates/team-agent/src/transport.rs +26 -12
  45. package/package.json +4 -4
  46. package/skills/team-agent/references/recovery-runbook.md +2 -2
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.105"
584
+ version = "0.5.107"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "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.105"
12
+ version = "0.5.107"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
package/README.md CHANGED
@@ -82,9 +82,11 @@ Subagents are one-shot, fire-and-forget, and can't talk to each other. Team Agen
82
82
 
83
83
  ## Status
84
84
 
85
- `team-agent status` prints one concise row per registered node with seven fields:
86
- `name`, `provider`, `runtime_status`, `activity`, `health`, `session_name`, and `tmux_command`.
87
- `--json` exposes the same projection as structured JSON. Missing or conflicting evidence is
85
+ `team-agent status` prints one concise row per registered node with nine fields:
86
+ `name`, `provider`, `model`, `effort`, `runtime_status`, `activity`, `health`, `session_name`, and `tmux_command`.
87
+ `model` and `effort` are the last accepted launch settings, or `null` when unspecified;
88
+ pending role-file edits and provider defaults are not inferred.
89
+ `--json` exposes the same projection as structured JSON. Missing or conflicting runtime evidence is
88
90
  reported as `unknown`; a non-null `tmux_command` is safe to copy for the observed socket,
89
91
  session, window, and pane. `--summary` and `--detail` remain accepted for compatibility and
90
92
  do not add diagnostic or history fields. The accepted nodeprobe pairing and
package/README.zh.md CHANGED
@@ -82,9 +82,10 @@ subagent 是一次性的、干完即焚、彼此不通信。Team Agent 的队友
82
82
 
83
83
  ## 状态
84
84
 
85
- `team-agent status` 为每个已登记节点输出七个简要字段:
86
- `name`、`provider`、`runtime_status`、`activity`、`health`、`session_name`、`tmux_command`。
87
- `--json` 与人读输出使用同一信息投影。证据缺失或冲突时显示 `unknown`;非空
85
+ `team-agent status` 为每个已登记节点输出九个简要字段:
86
+ `name`、`provider`、`model`、`effort`、`runtime_status`、`activity`、`health`、`session_name`、`tmux_command`。
87
+ `model`、`effort` 来自最近已接受的启动配置,未设置时为 `null`;不会把待启动的角色编辑或猜测的 provider 默认值当作已生效。
88
+ `--json` 与人读输出使用同一信息投影。运行态证据缺失或冲突时显示 `unknown`;非空
88
89
  `tmux_command` 可复制到已观测的 socket、session、window 和 pane。`--summary` 与
89
90
  `--detail` 保留兼容性,但不会增加诊断或历史字段。已验收 nodeprobe 的配对方式、
90
91
  operator-trusted 能力 receipt 与 fail-closed 边界见
@@ -174,7 +174,7 @@ fn append_reminder(text: String, reminder: &str) -> String {
174
174
  }
175
175
  }
176
176
 
177
- /// `cmd_status`(`commands.py:90`)。CLI status 统一为只读七字段 brief;
177
+ /// `cmd_status`(`commands.py:90`)。CLI status 统一为只读九字段 brief;
178
178
  /// `--json` 与人读路径共享 native tmux/process projection;`--summary`/`--detail` 保留参数兼容性但不暴露诊断。
179
179
  #[cfg(test)]
180
180
  pub(crate) fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
@@ -1640,6 +1640,20 @@ mod tests {
1640
1640
  );
1641
1641
  }
1642
1642
 
1643
+ #[test]
1644
+ fn reminders_only_recommend_supported_result_commands() {
1645
+ let reminder = crate::cli::QUICK_START_REMINDER;
1646
+ assert!(reminder.contains("Use team-agent status / inbox instead."));
1647
+ for text in [
1648
+ reminder,
1649
+ crate::cli::STATUS_REMINDER,
1650
+ crate::cli::SEND_REMINDER,
1651
+ &quickstart_human(&json!({"summary": "team started"})),
1652
+ ] {
1653
+ assert!(!text.contains("collect"), "removed CLI command leaked: {text}");
1654
+ }
1655
+ }
1656
+
1643
1657
  #[test]
1644
1658
  fn e13_quickstart_human_summary_only_when_no_attach() {
1645
1659
  let value = json!({"summary": "quick-start complete"});
@@ -340,15 +340,21 @@ fn command_help(command: Option<&str>) -> String {
340
340
  )
341
341
  .to_string(),
342
342
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
343
- Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出七字段:name/provider/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。缺少可靠定位或原生 tmux/process 采样时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
343
+ Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出九字段:name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。model/effort 为已接受启动配置,未设置为 null,不猜 provider 默认值。缺少可靠定位或原生 tmux/process 采样时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
344
344
  Some("models") => "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code] [QUERY|--search TEXT] [--json]\n\nLists exact provider model ids with case-insensitive multi-word search across provider, vendor, id, display name, and source aliases. Uses each provider's native catalog; Codex uses `codex debug models` and Claude uses SDK stream-json initialize.".to_string(),
345
345
  Some("leaders") => "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]\n\nLists LIVE leaders by default. Use --all to include retained STALE entries, --stale to inspect only STALE entries, QUERY or --search TEXT to match workspace/team/name fields, and --prune to remove only entries proven terminal by canonical state. --dry-run is valid only with --prune.".to_string(),
346
346
  Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
347
347
  Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json] [--detail]".to_string(),
348
348
  Some("reset-agent") => "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--json]".to_string(),
349
- Some("start-agent") => "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--json]\n\nAfter a successful start, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.".to_string(),
349
+ Some(name @ ("start-agent" | "add-agent")) => command_spec(name)
350
+ .map(|spec| format!(
351
+ "{}\n\n{}\n\nAfter a successful {}, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.",
352
+ spec.usage,
353
+ spec.summary,
354
+ if name == "start-agent" { "start" } else { "add" },
355
+ ))
356
+ .unwrap_or_else(|| format!("usage: team-agent {name} [options]")),
350
357
  Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
351
- Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--json]\n\nAfter a successful add, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.".to_string(),
352
358
  Some("clone-agent") => "usage: team-agent clone-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
353
359
  Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
354
360
  Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
@@ -2173,11 +2179,8 @@ mod tests {
2173
2179
  (
2174
2180
  "start-agent",
2175
2181
  &[
2176
- "--workspace",
2177
- "--team",
2178
- "--force",
2179
- "--allow-fresh",
2180
- "--json",
2182
+ "--model", "--effort", "--bypass", "--prompt", "--profile", "--provider",
2183
+ "--workspace", "--team", "--allow-fresh", "--json",
2181
2184
  ][..],
2182
2185
  ),
2183
2186
  (
@@ -2192,10 +2195,8 @@ mod tests {
2192
2195
  (
2193
2196
  "add-agent",
2194
2197
  &[
2195
- "--role-file",
2196
- "--workspace",
2197
- "--team",
2198
- "--json",
2198
+ "--role-file", "--provider", "--bypass", "--model", "--effort", "--prompt", "--profile",
2199
+ "--force", "--workspace", "--team", "--json",
2199
2200
  ][..],
2200
2201
  ),
2201
2202
  (
@@ -2282,11 +2283,30 @@ mod tests {
2282
2283
  );
2283
2284
  }
2284
2285
 
2286
+ #[test]
2287
+ fn worker_lifecycle_help_uses_authoritative_spec_usage_and_summary() {
2288
+ for name in ["start-agent", "add-agent"] {
2289
+ let spec = command_spec(name).expect("registered worker lifecycle command");
2290
+ let help = command_help(Some(name));
2291
+ assert_eq!(help.lines().next(), Some(spec.usage));
2292
+ assert!(help.contains(spec.summary));
2293
+ for flag in ["--model SLUG", "--effort LEVEL", "--bypass true|false", "--prompt TEXT", "--profile NAME", "--provider NAME"] {
2294
+ assert!(help.contains(flag), "{name} help missing {flag}: {help}");
2295
+ }
2296
+ assert!(help.contains("team-agent send AGENT MESSAGE"));
2297
+ }
2298
+ assert!(!command_help(Some("start-agent")).contains("--force"));
2299
+ let add = command_help(Some("add-agent"));
2300
+ assert!(add.contains("[--role-file FILE]"));
2301
+ assert!(add.contains("provider/bypass required from CLI or role file"));
2302
+ assert!(add.contains("conflicting values rejected"));
2303
+ }
2304
+
2285
2305
  #[test]
2286
2306
  fn status_help_describes_brief_projection_and_unknown_boundary() {
2287
2307
  let help = command_help(Some("status"));
2288
2308
  for marker in [
2289
- "name/provider/runtime_status/activity/health/session_name/tmux_command",
2309
+ "name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command",
2290
2310
  "人读与 --json 使用同一投影",
2291
2311
  "显示 unknown",
2292
2312
  "tmux_command",
@@ -49,7 +49,7 @@ use crate::messaging::{self, MessageTarget, SendOptions, TrustedSender};
49
49
  use crate::model::ids::{TaskId, TeamKey};
50
50
 
51
51
  pub(crate) const COMMS_BOUNDARY_TEXT: &str = "validates live pane binding consistency and zero-token comms contracts. Does NOT perform live runtime message round-trip. (zero token, zero pollution)";
52
- pub(crate) const QUICK_START_REMINDER: &str = "Reminder: Do not inspect raw worker pane output during normal operation. Use team-agent status / inbox / collect instead. Wait for report_result.";
52
+ pub(crate) const QUICK_START_REMINDER: &str = "Reminder: Do not inspect raw worker pane output during normal operation. Use team-agent status / inbox instead. Wait for report_result.";
53
53
  pub(crate) const SEND_REMINDER: &str = "Message delivered. Wait for the worker to report_result. Do not poll the worker terminal with capture-pane.";
54
54
  pub(crate) const STATUS_REMINDER: &str = "Results are finalized automatically; use --watch-result or team-agent inbox AGENT -n 3 for transport fallback. Do not capture-pane worker terminals.";
55
55
 
@@ -707,19 +707,7 @@ pub mod lifecycle_port {
707
707
  }
708
708
 
709
709
  fn state_generation(state: &Value) -> Option<&str> {
710
- state
711
- .get("generation")
712
- .and_then(Value::as_str)
713
- .or_else(|| {
714
- state
715
- .get("agents")
716
- .and_then(Value::as_object)
717
- .and_then(|agents| {
718
- agents.values().find_map(|agent| {
719
- agent.get("spawned_at").and_then(Value::as_str)
720
- })
721
- })
722
- })
710
+ crate::state::projection::session_generation(state)
723
711
  }
724
712
 
725
713
  fn state_for_session<'a>(state: &'a Value, session: &str) -> Option<&'a Value> {
@@ -777,12 +765,23 @@ pub mod lifecycle_port {
777
765
  if owner.generation != expected_generation {
778
766
  return SessionOwnership::Foreign;
779
767
  }
780
- if session_targets.iter().any(|target| {
781
- target.current_path.as_ref().map_or(true, |path| {
782
- !path_is_under(path.as_path(), workspace)
783
- })
784
- }) {
785
- return SessionOwnership::Unknown;
768
+ for target in session_targets {
769
+ if target.current_path.as_ref().is_some_and(|path| {
770
+ path_is_under(path.as_path(), workspace)
771
+ }) {
772
+ continue;
773
+ }
774
+ // An exited keepalive has no cwd or live process. Exempt only the
775
+ // internal anchor, with native positive dead-pane proof, AFTER the
776
+ // session workspace/team/generation ownership gates above.
777
+ let dead_anchor = target.current_path.as_ref().is_none_or(|path| {
778
+ path.as_os_str().is_empty()
779
+ }) && target.window_name.as_ref().is_some_and(|window| {
780
+ window.as_str() == crate::tmux_backend::KEEPALIVE_WINDOW_NAME
781
+ }) && matches!(transport.pane_is_dead(&target.pane_id), Ok(Some(true)));
782
+ if !dead_anchor {
783
+ return SessionOwnership::Unknown;
784
+ }
786
785
  }
787
786
  SessionOwnership::Owned
788
787
  }
@@ -793,7 +792,135 @@ pub mod lifecycle_port {
793
792
 
794
793
  use super::*;
795
794
  use crate::transport::test_support::OfflineTransport;
796
- use crate::transport::{PaneId, PaneInfo, SessionName};
795
+ use crate::transport::{PaneId, PaneInfo, SessionName, Transport, WindowName};
796
+
797
+ fn ownership_pane(session: &SessionName, pane: &str, window: &str, cwd: Option<PathBuf>) -> PaneInfo {
798
+ PaneInfo {
799
+ pane_id: PaneId::new(pane),
800
+ session: session.clone(),
801
+ window_index: None,
802
+ window_name: Some(WindowName::new(window)),
803
+ pane_index: None,
804
+ tty: None,
805
+ current_command: None,
806
+ current_path: cwd,
807
+ active: false,
808
+ pane_pid: None,
809
+ leader_env: BTreeMap::new(),
810
+ }
811
+ }
812
+
813
+ #[test]
814
+ fn dead_anchor_ownership_requires_native_dead_proof_and_all_owner_markers() {
815
+ let workspace = std::env::temp_dir().canonicalize().unwrap();
816
+ let session = SessionName::new("owned");
817
+ let state = json!({"team_key": "owned", "generation": "g"});
818
+ for (window, cwd, dead, team, generation, expected) in [
819
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "owned", "g", SessionOwnership::Owned),
820
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(false), "owned", "g", SessionOwnership::Unknown),
821
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, None, "owned", "g", SessionOwnership::Unknown),
822
+ ("foreign", None, Some(true), "owned", "g", SessionOwnership::Unknown),
823
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, Some(PathBuf::from("/foreign-workspace")), Some(true), "owned", "g", SessionOwnership::Unknown),
824
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "other-team", "g", SessionOwnership::Foreign),
825
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "owned", "other-generation", SessionOwnership::Foreign),
826
+ ] {
827
+ let mut transport = OfflineTransport::new().with_session_owner(&workspace, team, generation);
828
+ if let Some(dead) = dead { transport = transport.with_pane_dead("%1", dead); }
829
+ let targets = vec![
830
+ ownership_pane(&session, "%2", "assistant", Some(workspace.clone())),
831
+ ownership_pane(&session, "%1", window, cwd),
832
+ ];
833
+ assert_eq!(session_ownership(&workspace, &state, &transport, &session, &targets), expected);
834
+ if expected == SessionOwnership::Foreign {
835
+ assert!(!transport.calls().contains(&"pane_is_dead"), "owner gates must precede exemption");
836
+ }
837
+ assert!(!transport.calls().contains(&"kill_session"));
838
+ }
839
+ }
840
+
841
+ #[test]
842
+ #[cfg(unix)]
843
+ #[ignore = "isolated native tmux: explicit Grok execution, no provider subscription"]
844
+ fn dead_anchor_scoped_shutdown_reaps_worker_wrapper_and_spares_other_team() {
845
+ let root = std::env::temp_dir().join(format!(
846
+ "ta-r2-{}-{}", std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap(),
847
+ ));
848
+ std::fs::create_dir(&root).unwrap();
849
+ let root = root.canonicalize().unwrap();
850
+ let workspace = root.join("owned");
851
+ let foreign_workspace = root.join("foreign");
852
+ std::fs::create_dir(&workspace).unwrap();
853
+ std::fs::create_dir(&foreign_workspace).unwrap();
854
+ let endpoint = root.join("sock");
855
+ let transport = crate::tmux_backend::TmuxBackend::with_runner_for_tmux_endpoint(
856
+ Box::new(crate::tmux_backend::RealCommandRunner), endpoint.to_str().unwrap(),
857
+ );
858
+ let session = SessionName::new("round2-owned");
859
+ let foreign = SessionName::new("round2-foreign");
860
+ struct Cleanup<'a>(&'a crate::tmux_backend::TmuxBackend, [&'a SessionName; 2], &'a Path);
861
+ impl Drop for Cleanup<'_> {
862
+ fn drop(&mut self) {
863
+ for session in self.1 { let _ = self.0.kill_session(session); }
864
+ let _ = std::fs::remove_dir_all(self.2);
865
+ }
866
+ }
867
+ let _cleanup = Cleanup(&transport, [&session, &foreign], &root);
868
+ let env = BTreeMap::new();
869
+ let argv = vec!["sh".to_string(), "-c".to_string(), "exit 0".to_string()];
870
+ let first = transport.spawn_first_with_worker_shell_wrapper(
871
+ &session, &WindowName::new("assistant"), &argv, &workspace, &env, &[], "pi",
872
+ ).unwrap();
873
+ transport.set_session_owner_with_generation(&session, &workspace, "round2-owned", "g").unwrap();
874
+ transport.preserve_session_before_stop(&session, &[crate::transport::Target::Pane(first.pane_id.clone())]).unwrap();
875
+ transport.kill_pane(&first.pane_id).unwrap();
876
+ let worker = transport.spawn_into_with_worker_shell_wrapper(
877
+ &session, &WindowName::new("assistant"), &argv, &workspace, &env, &[], "pi",
878
+ ).unwrap();
879
+ let foreign_worker = transport.spawn_first(
880
+ &foreign, &WindowName::new("other"), &["sleep".to_string(), "60".to_string()],
881
+ &foreign_workspace, &env,
882
+ ).unwrap();
883
+ transport.set_session_owner_with_generation(&foreign, &foreign_workspace, "round2-foreign", "foreign-g").unwrap();
884
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
885
+ let marker = format!("{} 0", crate::tmux_backend::worker_provider_exit_marker("pi"));
886
+ loop {
887
+ // This controlled fixture prints nothing: the worker wrapper
888
+ // and its inert tail supply these two lines. provider_exit_status
889
+ // is a leader-only receipt, not a worker-wrapper observation.
890
+ let captured = transport.capture(
891
+ &crate::transport::Target::Pane(worker.pane_id.clone()),
892
+ crate::transport::CaptureRange::Full,
893
+ ).unwrap();
894
+ if captured.text.contains(&marker)
895
+ && captured.text.contains("Provider exited; this pane no longer accepts input.")
896
+ {
897
+ break;
898
+ }
899
+ assert!(std::time::Instant::now() < deadline, "worker must reach its inert exit wrapper: {}", captured.text);
900
+ std::thread::sleep(std::time::Duration::from_millis(20));
901
+ }
902
+ assert_eq!(transport.pane_is_dead(&worker.pane_id).unwrap(), Some(false));
903
+ let worker_pid = worker.child_pid.unwrap();
904
+ assert!(crate::platform::process::pid_is_alive(worker_pid));
905
+ let state = json!({
906
+ "team_key": "round2-owned", "active_team_key": "round2-owned",
907
+ "session_name": session.as_str(), "tmux_socket": endpoint.to_str().unwrap(),
908
+ "generation": "g", "is_external_leader": true,
909
+ "agents": {"assistant": {
910
+ "status": "running", "provider": "pi", "model": "openai-codex/gpt-6-luna", "effort": "max",
911
+ "window": "assistant", "pane_id": worker.pane_id.as_str(), "pane_pid": worker_pid,
912
+ "spawned_at": "new-worker-birth-after-start"
913
+ }}
914
+ });
915
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
916
+ let result = shutdown_with_transport_and_state(&workspace, true, None, &transport, Some(state)).unwrap();
917
+ assert_eq!(result["ok"], true, "{result}");
918
+ assert_eq!(result["residuals"], json!({"sessions": [], "processes": [], "owned_files": []}), "{result}");
919
+ assert!(!transport.has_session(&session).unwrap());
920
+ assert!(!crate::platform::process::pid_is_alive(worker_pid));
921
+ assert!(transport.has_session(&foreign).unwrap());
922
+ assert!(crate::platform::process::pid_is_alive(foreign_worker.child_pid.unwrap()));
923
+ }
797
924
 
798
925
  #[test]
799
926
  fn leader_process_protection_does_not_alias_panes_across_endpoints() {
@@ -1,4 +1,4 @@
1
- //! Seven-field, read-only status projection backed by native tmux and process samples.
1
+ //! Read-only status: native runtime samples plus accepted launch model/effort.
2
2
  //!
3
3
  //! This intentionally does not reuse RuntimeSnapshot: the legacy snapshot reads
4
4
  //! coordinator/db/history and carries diagnostic fields that are outside the
@@ -82,6 +82,8 @@ static NODEPROBE_RESOLVER_ACTIVE: AtomicBool = AtomicBool::new(false);
82
82
  struct RegisteredNode {
83
83
  name: String,
84
84
  provider: String,
85
+ model: Option<String>,
86
+ effort: Option<String>,
85
87
  endpoint: Option<String>,
86
88
  session: Option<String>,
87
89
  window: Option<String>,
@@ -106,6 +108,8 @@ struct ProbeNode {
106
108
  struct BriefNode {
107
109
  name: String,
108
110
  provider: String,
111
+ model: Option<String>,
112
+ effort: Option<String>,
109
113
  runtime_status: String,
110
114
  activity: String,
111
115
  health: String,
@@ -157,7 +161,7 @@ pub(crate) fn status_brief_scoped(
157
161
  })
158
162
  }
159
163
 
160
- /// Human output is the exact same seven-field projection as JSON, rendered in
164
+ /// Human output is the exact same nine-field projection as JSON, rendered in
161
165
  /// stable field order. Null values remain explicit rather than becoming a
162
166
  /// diagnostic or an inferred value.
163
167
  pub(crate) fn format_status_brief(
@@ -305,9 +309,11 @@ fn write_test_capability_receipt(path: &Path) {
305
309
 
306
310
  fn format_brief_node(value: &Value) -> Option<String> {
307
311
  Some(format!(
308
- "name: {} provider: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
312
+ "name: {} provider: {} model: {} effort: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
309
313
  value.get("name")?.as_str()?,
310
314
  value.get("provider")?.as_str()?,
315
+ display_optional(value.get("model")?),
316
+ display_optional(value.get("effort")?),
311
317
  value.get("runtime_status")?.as_str()?,
312
318
  value.get("activity")?.as_str()?,
313
319
  value.get("health")?.as_str()?,
@@ -369,6 +375,10 @@ fn registered_node(name: &str, value: &Value, state: &Value) -> RegisteredNode {
369
375
  RegisteredNode {
370
376
  name: name.to_string(),
371
377
  provider,
378
+ // Accepted launch settings, not pending role-file edits or guessed
379
+ // provider defaults. Runtime liveness remains a separate signal.
380
+ model: string_at(value, &["model"]),
381
+ effort: string_at(value, &["effort"]),
372
382
  endpoint: endpoint(value).or_else(|| endpoint(state)),
373
383
  session: first_string(value, &["session_name", "session"])
374
384
  .or_else(|| {
@@ -1052,6 +1062,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
1052
1062
  MatchResult::Missing => BriefNode {
1053
1063
  name: node.name.clone(),
1054
1064
  provider: node.provider.clone(),
1065
+ model: node.model.clone(),
1066
+ effort: node.effort.clone(),
1055
1067
  runtime_status: "stopped".to_string(),
1056
1068
  activity: "unknown".to_string(),
1057
1069
  health: "unknown".to_string(),
@@ -1092,6 +1104,8 @@ fn project_node(node: &RegisteredNode, probe: Option<&Option<ProbeResult>>) -> B
1092
1104
  } else {
1093
1105
  node.provider.clone()
1094
1106
  },
1107
+ model: node.model.clone(),
1108
+ effort: node.effort.clone(),
1095
1109
  runtime_status: "running".to_string(),
1096
1110
  activity: activity.to_string(),
1097
1111
  health: health.to_string(),
@@ -1172,6 +1186,8 @@ impl BriefNode {
1172
1186
  Self {
1173
1187
  name: node.name.clone(),
1174
1188
  provider: node.provider.clone(),
1189
+ model: node.model.clone(),
1190
+ effort: node.effort.clone(),
1175
1191
  runtime_status: "unknown".to_string(),
1176
1192
  activity: "unknown".to_string(),
1177
1193
  health: "unknown".to_string(),
@@ -1184,6 +1200,8 @@ impl BriefNode {
1184
1200
  let mut object = Map::new();
1185
1201
  object.insert("name".to_string(), json!(self.name));
1186
1202
  object.insert("provider".to_string(), json!(self.provider));
1203
+ object.insert("model".to_string(), json!(self.model));
1204
+ object.insert("effort".to_string(), json!(self.effort));
1187
1205
  object.insert("runtime_status".to_string(), json!(self.runtime_status));
1188
1206
  object.insert("activity".to_string(), json!(self.activity));
1189
1207
  object.insert("health".to_string(), json!(self.health));
@@ -1208,6 +1226,8 @@ mod tests {
1208
1226
  RegisteredNode {
1209
1227
  name: "worker".to_string(),
1210
1228
  provider: "pi".to_string(),
1229
+ model: None,
1230
+ effort: None,
1211
1231
  endpoint: Some("/tmp/tmux socket/o'k".to_string()),
1212
1232
  session: Some("sess".to_string()),
1213
1233
  window: Some("win".to_string()),
@@ -1234,6 +1254,45 @@ mod tests {
1234
1254
  br#"{"schema_version":1,"socket":"/tmp/sock","sampled_at":"2026-01-01T00:00:00Z","nodes":[],"error":{"kind":"tmux_inventory","message":"missing"}}"#
1235
1255
  }
1236
1256
 
1257
+ #[test]
1258
+ fn status_model_effort_project_accepted_launch_settings_in_all_runtime_states() {
1259
+ for settings in [(None, None), (Some("openai-codex/gpt-6-luna"), Some("max"))] {
1260
+ let mut node = registered();
1261
+ node.model = settings.0.map(str::to_string);
1262
+ node.effort = settings.1.map(str::to_string);
1263
+ for lifecycle in [None, Some("stopped")] {
1264
+ node.lifecycle = lifecycle.map(str::to_string);
1265
+ for sample in [None, Some(ProbeResult { nodes: vec![probe("pi_activity_channel")] })] {
1266
+ let value = project_node(&node, Some(&sample)).into_json();
1267
+ assert_eq!(value["model"], json!(settings.0));
1268
+ assert_eq!(value["effort"], json!(settings.1));
1269
+ let human = format_brief_node(&value).unwrap();
1270
+ assert!(human.contains(&format!("model: {} effort: {}", settings.0.unwrap_or("null"), settings.1.unwrap_or("null"))));
1271
+ assert_eq!(value.as_object().unwrap().len(), 9);
1272
+ }
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ #[test]
1278
+ fn status_model_effort_use_canonical_runtime_roster_not_stale_projection() {
1279
+ let state = json!({
1280
+ "active_team_key": "selected",
1281
+ "agents": {"worker": {"model": "stale-model", "effort": "low"}},
1282
+ "teams": {"selected": {"agents": {
1283
+ "worker": {"model": "accepted-model", "effort": "max"},
1284
+ "plain": {}
1285
+ }}}
1286
+ });
1287
+ let selected = registered_nodes(&state, Some("worker"));
1288
+ assert_eq!(selected.len(), 1);
1289
+ assert_eq!(selected[0].model.as_deref(), Some("accepted-model"));
1290
+ assert_eq!(selected[0].effort.as_deref(), Some("max"));
1291
+ let plain = registered_nodes(&state, Some("plain"));
1292
+ assert!(plain[0].model.is_none());
1293
+ assert!(plain[0].effort.is_none());
1294
+ }
1295
+
1237
1296
  #[test]
1238
1297
  fn pi_pane_title_is_unknown_not_idle() {
1239
1298
  let node = registered();