@team-agent/installer 0.5.106 → 0.5.108

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 (41) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -1
  4. package/crates/team-agent/src/cli/emit.rs +3 -3
  5. package/crates/team-agent/src/cli/mod.rs +87 -4
  6. package/crates/team-agent/src/cli/models.rs +252 -3
  7. package/crates/team-agent/src/cli/spec.rs +1 -1
  8. package/crates/team-agent/src/cli/status_port/brief.rs +155 -37
  9. package/crates/team-agent/src/cli/tests/base.rs +112 -225
  10. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +184 -0
  11. package/crates/team-agent/src/cli/tests/status_brief.rs +624 -0
  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/tests/agent_ops.rs +0 -9
  18. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_native_mcp_host.mjs +71 -0
  19. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +0 -66
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +0 -392
  21. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +0 -5
  22. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +2 -361
  23. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +1 -16
  24. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +20 -112
  25. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1 -12
  26. package/crates/team-agent/src/lifecycle/tests/pi_native_mcp_capability_red.rs +546 -0
  27. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  28. package/crates/team-agent/src/lifecycle/worker_command_context.rs +11 -54
  29. package/crates/team-agent/src/messaging/tests/wave2.rs +2 -302
  30. package/crates/team-agent/src/model/name_similarity.rs +52 -67
  31. package/crates/team-agent/src/model/task_graph.rs +37 -124
  32. package/crates/team-agent/src/model/yaml/tests.rs +46 -165
  33. package/crates/team-agent/src/provider/model_catalog.rs +166 -3
  34. package/crates/team-agent/src/provider/testdata/cursor-list-models-native.stdout +250 -0
  35. package/crates/team-agent/src/provider/testdata/grok-models-authenticated.stdout +9 -0
  36. package/crates/team-agent/src/provider/testdata/grok-models-unauthenticated.stdout +7 -0
  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/tmux_backend/tests.rs +178 -501
  40. package/crates/team-agent/src/transport/tests/wire.rs +0 -55
  41. package/package.json +4 -4
@@ -58,6 +58,165 @@ fn production_state(agents: Value) -> Value {
58
58
  })
59
59
  }
60
60
 
61
+ struct BriefWorkspaceGuard(PathBuf);
62
+
63
+ impl std::ops::Deref for BriefWorkspaceGuard {
64
+ type Target = Path;
65
+
66
+ fn deref(&self) -> &Self::Target {
67
+ &self.0
68
+ }
69
+ }
70
+
71
+ impl Drop for BriefWorkspaceGuard {
72
+ fn drop(&mut self) {
73
+ let _ = std::fs::remove_dir_all(&self.0);
74
+ }
75
+ }
76
+
77
+ fn guarded_brief_workspace(tag: &str) -> BriefWorkspaceGuard {
78
+ BriefWorkspaceGuard(brief_workspace(tag))
79
+ }
80
+
81
+ fn status_agent(
82
+ name: &str,
83
+ pane: &str,
84
+ endpoint: &str,
85
+ session: &str,
86
+ model: &str,
87
+ effort: &str,
88
+ ) -> Value {
89
+ let mut agent = production_agent(name, pane);
90
+ agent["tmux_endpoint"] = json!(endpoint);
91
+ agent["session_name"] = json!(session);
92
+ agent["model"] = json!(model);
93
+ agent["effort"] = json!(effort);
94
+ agent
95
+ }
96
+
97
+ #[cfg(unix)]
98
+ fn producer_report_nodes(socket: &str, rows: &[(String, String, String, String)]) -> String {
99
+ let nodes = rows
100
+ .iter()
101
+ .map(|(session, window, pane, native_session)| {
102
+ json!({
103
+ "socket": socket,
104
+ "workspace_path": "/ws",
105
+ "project_name": "demo",
106
+ "session": session,
107
+ "window_index": 1,
108
+ "window_name": window,
109
+ "pane_id": pane,
110
+ "name": window,
111
+ "provider": "pi",
112
+ "state": "idle",
113
+ "activity": "idle",
114
+ "session_name": native_session,
115
+ "health": "normal",
116
+ "background_tasks": {"running": 0},
117
+ "evidence": {"method": "pi_activity_channel", "detail": "channel"}
118
+ })
119
+ })
120
+ .collect::<Vec<_>>();
121
+ serde_json::to_string(&json!({
122
+ "schema_version": 1,
123
+ "socket": socket,
124
+ "sampled_at": "2026-01-01T00:00:00Z",
125
+ "nodes": nodes
126
+ }))
127
+ .unwrap()
128
+ }
129
+
130
+ #[cfg(unix)]
131
+ fn write_nodeprobe_reports(dir: &Path, reports: &[(String, String)]) -> (PathBuf, PathBuf) {
132
+ std::fs::create_dir_all(dir).unwrap();
133
+ let log = dir.join("calls.log");
134
+ let mut body = format!(
135
+ "printf '%s\\t%s\\n' \"$1\" \"$2\" >> {}\ncase \"$2\" in\n",
136
+ shell_quote_for_status_test(&log.to_string_lossy())
137
+ );
138
+ for (index, (endpoint, report)) in reports.iter().enumerate() {
139
+ let path = dir.join(format!("report-{index}.json"));
140
+ std::fs::write(&path, report).unwrap();
141
+ body.push_str(&format!(
142
+ " {} ) cat {} ;;\n",
143
+ shell_quote_for_status_test(endpoint),
144
+ shell_quote_for_status_test(&path.to_string_lossy())
145
+ ));
146
+ }
147
+ body.push_str(" *) exit 2 ;;\nesac\n");
148
+ (write_nodeprobe(dir, &body), log)
149
+ }
150
+
151
+ fn shell_quote_for_status_test(value: &str) -> String {
152
+ format!("'{}'", value.replace('\'', "'\\''"))
153
+ }
154
+
155
+ fn logged_probe_calls(log: &Path) -> Vec<(String, String)> {
156
+ std::fs::read_to_string(log)
157
+ .unwrap()
158
+ .lines()
159
+ .map(|line| {
160
+ let (flag, endpoint) = line.split_once('\t').expect("probe call fields");
161
+ (flag.to_string(), endpoint.to_string())
162
+ })
163
+ .collect()
164
+ }
165
+
166
+ fn human_text(result: CmdResult) -> String {
167
+ match result.output {
168
+ CmdOutput::Human(text) => text,
169
+ other => panic!("expected human status output, got {other:?}"),
170
+ }
171
+ }
172
+
173
+ fn legacy_human_node(node: &Value) -> String {
174
+ let text = |key: &str| {
175
+ node.get(key)
176
+ .and_then(Value::as_str)
177
+ .unwrap_or("null")
178
+ .to_string()
179
+ };
180
+ format!(
181
+ "name: {} provider: {} model: {} effort: {} runtime_status: {} activity: {} health: {} session_name: {} tmux_command: {}",
182
+ text("name"),
183
+ text("provider"),
184
+ text("model"),
185
+ text("effort"),
186
+ text("runtime_status"),
187
+ text("activity"),
188
+ text("health"),
189
+ text("session_name"),
190
+ text("tmux_command")
191
+ )
192
+ }
193
+
194
+ fn human_token_count(text: &str, expected: &str) -> usize {
195
+ text.split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
196
+ .filter(|token| token.eq_ignore_ascii_case(expected))
197
+ .count()
198
+ }
199
+
200
+ fn status_table_header(text: &str) -> Option<&str> {
201
+ text.lines().find(|line| {
202
+ !line.trim_start().starts_with("name:")
203
+ && [
204
+ "name",
205
+ "provider",
206
+ "model",
207
+ "effort",
208
+ "activity",
209
+ "health",
210
+ "session_name",
211
+ ]
212
+ .iter()
213
+ .all(|field| human_token_count(line, field) == 1)
214
+ && human_token_count(line, "runtime_status") + human_token_count(line, "runtime")
215
+ == 1
216
+ && human_token_count(line, "tmux_command") + human_token_count(line, "attach") == 1
217
+ })
218
+ }
219
+
61
220
  fn status_args(ws: &Path, json_out: bool, agent: Option<&str>, team: Option<&str>) -> StatusArgs {
62
221
  StatusArgs {
63
222
  agent: agent.map(str::to_string),
@@ -212,6 +371,471 @@ fn cmd_status_dedupes_same_endpoint_across_agents() {
212
371
  let _ = std::fs::remove_dir_all(&ws);
213
372
  }
214
373
 
374
+ #[cfg(unix)]
375
+ #[test]
376
+ #[serial(status_brief)]
377
+ fn issue271_status_human_dedupes_six_nodes_and_preserves_json() {
378
+ let ws = guarded_brief_workspace("issue271-six");
379
+ let endpoint = "/tmp/271 shared.sock";
380
+ let session = "team-271";
381
+ let mut agents = serde_json::Map::new();
382
+ let mut rows = Vec::new();
383
+ for index in 1..=6 {
384
+ let name = format!("worker-{index}");
385
+ let pane = format!("%{}", index + 20);
386
+ agents.insert(
387
+ name.clone(),
388
+ status_agent(
389
+ &name,
390
+ &pane,
391
+ endpoint,
392
+ session,
393
+ &format!("gpt-5.6-luna-{index}"),
394
+ if index % 2 == 0 { "high" } else { "max" },
395
+ ),
396
+ );
397
+ rows.push((
398
+ session.to_string(),
399
+ name.clone(),
400
+ pane,
401
+ format!("native-{name}"),
402
+ ));
403
+ }
404
+ write_state(
405
+ &ws,
406
+ &json!({
407
+ "session_name": session,
408
+ "tmux_endpoint": endpoint,
409
+ "active_team_key": "demo",
410
+ "agents": agents
411
+ }),
412
+ );
413
+ let report = producer_report_nodes(endpoint, &rows);
414
+ let (bin, log) = write_nodeprobe_reports(
415
+ &ws.join("probe"),
416
+ &[(endpoint.to_string(), report)],
417
+ );
418
+ let nodes = status_port::with_test_nodeprobe(bin.clone(), || {
419
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("JSON status"))
420
+ });
421
+ assert_eq!(nodes.len(), 6);
422
+ for node in &nodes {
423
+ assert_eq!(node.as_object().unwrap().len(), 9);
424
+ for key in [
425
+ "name",
426
+ "provider",
427
+ "model",
428
+ "effort",
429
+ "runtime_status",
430
+ "activity",
431
+ "health",
432
+ "session_name",
433
+ "tmux_command",
434
+ ] {
435
+ assert!(node.get(key).is_some(), "missing JSON field {key}");
436
+ }
437
+ let name = node["name"].as_str().unwrap();
438
+ let index = name.strip_prefix("worker-").unwrap().parse::<u32>().unwrap();
439
+ let pane = index + 20;
440
+ assert_eq!(node["provider"], "pi");
441
+ assert_eq!(node["model"], format!("gpt-5.6-luna-{index}"));
442
+ assert_eq!(node["effort"], if index % 2 == 0 { "high" } else { "max" });
443
+ assert_eq!(node["runtime_status"], "running");
444
+ assert_eq!(node["activity"], "idle");
445
+ assert_eq!(node["health"], "normal");
446
+ assert_eq!(node["session_name"], format!("native-{name}"));
447
+ let target = format!("{session}:{name}.%{pane}");
448
+ assert_eq!(
449
+ node["tmux_command"],
450
+ json!(format!(
451
+ "tmux -S {} attach -t {}",
452
+ shell_quote_for_status_test(endpoint),
453
+ shell_quote_for_status_test(&target)
454
+ ))
455
+ );
456
+ }
457
+ let before = nodes
458
+ .iter()
459
+ .map(legacy_human_node)
460
+ .collect::<Vec<_>>()
461
+ .join("\n");
462
+ let human = status_port::with_test_nodeprobe(bin, || {
463
+ human_text(cmd_status(&status_args(&ws, false, None, None)).expect("human status"))
464
+ });
465
+ let prefix = format!("tmux -S {}", shell_quote_for_status_test(endpoint));
466
+ let prefix_count = human.matches(&prefix).count();
467
+ let labels_once = status_table_header(&human).is_some();
468
+ let targets_once = nodes.iter().all(|node| {
469
+ let name = node["name"].as_str().unwrap();
470
+ let index = name.strip_prefix("worker-").unwrap().parse::<u32>().unwrap();
471
+ let pane = index + 20;
472
+ let target = format!("{name}.%{pane}");
473
+ let rows = human.lines().filter(|line| line.contains(&target)).collect::<Vec<_>>();
474
+ rows.len() == 1 && rows[0].contains("1:")
475
+ });
476
+ let calls = logged_probe_calls(&log);
477
+ assert_eq!(calls.len(), 2, "one native query per status invocation: {calls:?}");
478
+ assert!(
479
+ calls.iter().all(|(flag, called)| flag == "-S" && called == endpoint),
480
+ "{calls:?}"
481
+ );
482
+ assert!(
483
+ prefix_count == 1 && labels_once && targets_once && human.len() * 4 < before.len() * 3,
484
+ "six-node human output must dedupe shared text and retain targets; prefix_count={prefix_count}, labels_once={labels_once}, targets_once={targets_once}, before_bytes={}, after_bytes={}\n{human}",
485
+ before.len(),
486
+ human.len()
487
+ );
488
+ }
489
+
490
+ #[cfg(unix)]
491
+ #[test]
492
+ #[serial(status_brief)]
493
+ fn issue271_status_human_groups_socket_session_pairs_without_cross_routing() {
494
+ let ws = guarded_brief_workspace("issue271-groups");
495
+ let endpoint_a = "/tmp/271 socket 'α.sock";
496
+ let endpoint_b = "shared named socket";
497
+ let session_red = "red team α";
498
+ let session_blue = "blue's team";
499
+ let specs = [
500
+ ("a-red", endpoint_a, session_red, "%31", 1),
501
+ ("b-red", endpoint_a, session_red, "%32", 1),
502
+ ("c-other", endpoint_a, session_blue, "%33", 2),
503
+ ("d-named", endpoint_b, session_red, "%34", 3),
504
+ ("e-named", endpoint_b, session_red, "%35", 3),
505
+ ];
506
+ let mut agents = serde_json::Map::new();
507
+ let mut rows_a = Vec::new();
508
+ let mut rows_b = Vec::new();
509
+ for &(name, endpoint, session, pane, _) in &specs {
510
+ agents.insert(
511
+ name.to_string(),
512
+ status_agent(name, pane, endpoint, session, "model", "high"),
513
+ );
514
+ let row = (
515
+ session.to_string(),
516
+ name.to_string(),
517
+ pane.to_string(),
518
+ format!("native-{name}"),
519
+ );
520
+ if endpoint == endpoint_a {
521
+ rows_a.push(row);
522
+ } else {
523
+ rows_b.push(row);
524
+ }
525
+ }
526
+ write_state(
527
+ &ws,
528
+ &json!({
529
+ "session_name": session_red,
530
+ "tmux_endpoint": endpoint_a,
531
+ "active_team_key": "demo",
532
+ "agents": agents
533
+ }),
534
+ );
535
+ let reports = [
536
+ (
537
+ endpoint_a.to_string(),
538
+ producer_report_nodes(endpoint_a, &rows_a),
539
+ ),
540
+ (
541
+ endpoint_b.to_string(),
542
+ producer_report_nodes(endpoint_b, &rows_b),
543
+ ),
544
+ ];
545
+ let (bin, log) = write_nodeprobe_reports(&ws.join("probe"), &reports);
546
+ let nodes = status_port::with_test_nodeprobe(bin.clone(), || {
547
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("JSON status"))
548
+ });
549
+ assert_eq!(nodes.len(), specs.len());
550
+ for node in &nodes {
551
+ let name = node["name"].as_str().unwrap();
552
+ let (_, endpoint, session, pane, _) =
553
+ *specs.iter().find(|spec| spec.0 == name).unwrap();
554
+ let target = format!("{session}:{name}.{pane}");
555
+ assert_eq!(
556
+ node["tmux_command"],
557
+ json!(format!(
558
+ "tmux {} {} attach -t {}",
559
+ if Path::new(endpoint).is_absolute() { "-S" } else { "-L" },
560
+ shell_quote_for_status_test(endpoint),
561
+ shell_quote_for_status_test(&target)
562
+ ))
563
+ );
564
+ }
565
+ let human = status_port::with_test_nodeprobe(bin, || {
566
+ human_text(cmd_status(&status_args(&ws, false, None, None)).expect("human status"))
567
+ });
568
+ let abs_prefix = format!("tmux -S {}", shell_quote_for_status_test(endpoint_a));
569
+ let named_prefix = format!("tmux -L {}", shell_quote_for_status_test(endpoint_b));
570
+ let calls = logged_probe_calls(&log);
571
+ assert_eq!(calls.len(), 4, "one probe per endpoint per invocation: {calls:?}");
572
+ assert_eq!(calls.iter().filter(|(_, value)| value == endpoint_a).count(), 2);
573
+ assert_eq!(calls.iter().filter(|(_, value)| value == endpoint_b).count(), 2);
574
+ assert!(calls.iter().all(|(flag, value)| {
575
+ (value == endpoint_a && flag == "-S") || (value == endpoint_b && flag == "-L")
576
+ }));
577
+ let expected_groups = [
578
+ ("a-red", "%31", 1),
579
+ ("b-red", "%32", 1),
580
+ ("c-other", "%33", 2),
581
+ ("d-named", "%34", 3),
582
+ ("e-named", "%35", 3),
583
+ ];
584
+ let rows_routed = expected_groups.iter().all(|(name, pane, group)| {
585
+ let target = format!("{name}.{pane}");
586
+ let matching = human
587
+ .lines()
588
+ .filter(|line| line.contains(&target))
589
+ .collect::<Vec<_>>();
590
+ matching.len() == 1 && matching[0].contains(&format!("{group}:"))
591
+ });
592
+ assert!(
593
+ human.matches(&abs_prefix).count() == 2
594
+ && human.matches(&named_prefix).count() == 1
595
+ && human.matches(session_red).count() == 2
596
+ && human.matches("blue").count() == 1
597
+ && rows_routed,
598
+ "each (endpoint, session) needs one stable group and its own targets; abs={}, named={}, red={}, blue={}, rows_routed={rows_routed}\n{human}",
599
+ human.matches(&abs_prefix).count(),
600
+ human.matches(&named_prefix).count(),
601
+ human.matches(session_red).count(),
602
+ human.matches("blue").count()
603
+ );
604
+ }
605
+
606
+ #[cfg(unix)]
607
+ #[test]
608
+ #[serial(status_brief)]
609
+ fn issue271_status_single_empty_unknown_and_stopped_compatibility() {
610
+ let endpoint = "/tmp/271-single.sock";
611
+ let single = guarded_brief_workspace("issue271-single");
612
+ write_state(
613
+ &single,
614
+ &json!({
615
+ "session_name": "team-single",
616
+ "tmux_endpoint": endpoint,
617
+ "agents": {
618
+ "solo": status_agent(
619
+ "solo",
620
+ "%7",
621
+ endpoint,
622
+ "team-single",
623
+ "gpt-5.6-luna-xhigh",
624
+ "high"
625
+ )
626
+ }
627
+ }),
628
+ );
629
+ let single_report = producer_report_nodes(
630
+ endpoint,
631
+ &[(
632
+ "team-single".to_string(),
633
+ "solo".to_string(),
634
+ "%7".to_string(),
635
+ "pi-native-solo".to_string(),
636
+ )],
637
+ );
638
+ let (single_bin, single_log) =
639
+ write_nodeprobe_reports(&single.join("probe"), &[(endpoint.to_string(), single_report)]);
640
+ let single_nodes = status_port::with_test_nodeprobe(single_bin.clone(), || {
641
+ json_nodes(cmd_status(&status_args(&single, true, None, None)).expect("single JSON"))
642
+ });
643
+ let single_human = status_port::with_test_nodeprobe(single_bin, || {
644
+ human_text(cmd_status(&status_args(&single, false, None, None)).expect("single human"))
645
+ });
646
+ assert_eq!(single_nodes.len(), 1);
647
+ assert_eq!(single_human, legacy_human_node(&single_nodes[0]));
648
+ assert_eq!(logged_probe_calls(&single_log).len(), 2);
649
+ let unknown_agent = cmd_status(&status_args(&single, false, Some("ghost"), None))
650
+ .unwrap_err()
651
+ .to_string();
652
+ assert!(unknown_agent.contains("unknown agent id: ghost"), "{unknown_agent}");
653
+
654
+ let empty = guarded_brief_workspace("issue271-empty");
655
+ write_state(&empty, &production_state(json!({})));
656
+ assert!(
657
+ human_text(cmd_status(&status_args(&empty, false, None, None)).expect("empty")).is_empty()
658
+ );
659
+
660
+ let mixed = guarded_brief_workspace("issue271-mixed");
661
+ let mut agents = serde_json::Map::new();
662
+ agents.insert(
663
+ "worker".to_string(),
664
+ status_agent("worker", "%7", endpoint, "team-mixed", "gpt-worker", "max"),
665
+ );
666
+ agents.insert(
667
+ "unknown".to_string(),
668
+ status_agent("unknown", "%8", endpoint, "team-mixed", "gpt-unknown", "high"),
669
+ );
670
+ let mut stopped = status_agent(
671
+ "stopped",
672
+ "%9",
673
+ endpoint,
674
+ "team-mixed",
675
+ "gpt-stopped",
676
+ "low",
677
+ );
678
+ stopped["status"] = json!("stopped");
679
+ agents.insert("stopped".to_string(), stopped);
680
+ write_state(
681
+ &mixed,
682
+ &json!({
683
+ "session_name": "team-mixed",
684
+ "tmux_endpoint": endpoint,
685
+ "agents": agents
686
+ }),
687
+ );
688
+ let mixed_report = producer_report_nodes(
689
+ endpoint,
690
+ &[(
691
+ "team-mixed".to_string(),
692
+ "worker".to_string(),
693
+ "%7".to_string(),
694
+ "pi-native-worker".to_string(),
695
+ )],
696
+ );
697
+ let (mixed_bin, mixed_log) =
698
+ write_nodeprobe_reports(&mixed.join("probe"), &[(endpoint.to_string(), mixed_report)]);
699
+ let mixed_nodes = status_port::with_test_nodeprobe(mixed_bin.clone(), || {
700
+ json_nodes(cmd_status(&status_args(&mixed, true, None, None)).expect("mixed JSON"))
701
+ });
702
+ let node = |name: &str| mixed_nodes.iter().find(|node| node["name"] == name).unwrap();
703
+ assert_eq!(node("worker")["runtime_status"], "running");
704
+ assert_eq!(node("worker")["session_name"], "pi-native-worker");
705
+ assert_eq!(node("unknown")["runtime_status"], "unknown");
706
+ assert_eq!(node("unknown")["model"], "gpt-unknown");
707
+ assert_eq!(node("unknown")["effort"], "high");
708
+ assert!(node("unknown")["tmux_command"].is_null());
709
+ assert!(node("unknown")["session_name"].is_null());
710
+ assert_eq!(node("stopped")["runtime_status"], "stopped");
711
+ assert_eq!(node("stopped")["model"], "gpt-stopped");
712
+ assert_eq!(node("stopped")["effort"], "low");
713
+ assert!(node("stopped")["tmux_command"].is_null());
714
+ let mixed_human = status_port::with_test_nodeprobe(mixed_bin.clone(), || {
715
+ human_text(cmd_status(&status_args(&mixed, false, None, None)).expect("mixed human"))
716
+ });
717
+ let agent_human = status_port::with_test_nodeprobe(mixed_bin, || {
718
+ human_text(
719
+ cmd_status(&status_args(&mixed, false, Some("worker"), None))
720
+ .expect("status AGENT"),
721
+ )
722
+ });
723
+ assert_eq!(agent_human, legacy_human_node(node("worker")));
724
+ let calls = logged_probe_calls(&mixed_log);
725
+ assert_eq!(calls.len(), 3, "one native query per status invocation");
726
+ assert!(
727
+ status_table_header(&mixed_human).is_some()
728
+ && mixed_human
729
+ .matches(&format!("tmux -S {}", shell_quote_for_status_test(endpoint)))
730
+ .count()
731
+ == 1,
732
+ "mixed output must retain one shared header and only the observed worker target:\n{mixed_human}"
733
+ );
734
+ }
735
+
736
+ #[cfg(unix)]
737
+ #[test]
738
+ #[serial(status_brief)]
739
+ fn issue271_status_human_escapes_fields_and_quotes_shared_template() {
740
+ let ws = guarded_brief_workspace("issue271-escape");
741
+ let endpoint = "/tmp/271 space '雪.sock";
742
+ let session = "team '雪";
743
+ let window_a = "win '甲";
744
+ let window_b = "win β";
745
+ let mut agent_a = status_agent(
746
+ "node-a",
747
+ "%51",
748
+ endpoint,
749
+ session,
750
+ "gpt\tα\nmax",
751
+ "xhigh\nhigh",
752
+ );
753
+ agent_a["window"] = json!(window_a);
754
+ let mut agent_b = status_agent(
755
+ "node-b",
756
+ "%52",
757
+ endpoint,
758
+ session,
759
+ "grok\tβ\nxhigh",
760
+ "high\tmax\nlow",
761
+ );
762
+ agent_b["window"] = json!(window_b);
763
+ write_state(
764
+ &ws,
765
+ &json!({
766
+ "session_name": session,
767
+ "tmux_endpoint": endpoint,
768
+ "agents": {"node-a": agent_a, "node-b": agent_b}
769
+ }),
770
+ );
771
+ let report = producer_report_nodes(
772
+ endpoint,
773
+ &[
774
+ (
775
+ session.to_string(),
776
+ window_a.to_string(),
777
+ "%51".to_string(),
778
+ "pi\tα\nchannel".to_string(),
779
+ ),
780
+ (
781
+ session.to_string(),
782
+ window_b.to_string(),
783
+ "%52".to_string(),
784
+ "pi β native".to_string(),
785
+ ),
786
+ ],
787
+ );
788
+ let (bin, log) = write_nodeprobe_reports(
789
+ &ws.join("probe"),
790
+ &[(endpoint.to_string(), report)],
791
+ );
792
+ let nodes = status_port::with_test_nodeprobe(bin.clone(), || {
793
+ json_nodes(cmd_status(&status_args(&ws, true, None, None)).expect("JSON status"))
794
+ });
795
+ let node_a = nodes.iter().find(|node| node["name"] == "node-a").unwrap();
796
+ assert_eq!(node_a["model"], "gpt\tα\nmax");
797
+ assert_eq!(node_a["effort"], "xhigh\nhigh");
798
+ assert_eq!(node_a["session_name"], "pi\tα\nchannel");
799
+ let target_a = format!("{session}:{window_a}.%51");
800
+ assert_eq!(
801
+ node_a["tmux_command"],
802
+ json!(format!(
803
+ "tmux -S {} attach -t {}",
804
+ shell_quote_for_status_test(endpoint),
805
+ shell_quote_for_status_test(&target_a)
806
+ ))
807
+ );
808
+ let human = status_port::with_test_nodeprobe(bin, || {
809
+ human_text(cmd_status(&status_args(&ws, false, None, None)).expect("human status"))
810
+ });
811
+ let row_a = human
812
+ .lines()
813
+ .find(|line| line.contains("node-a"))
814
+ .unwrap_or("");
815
+ let row_b = human
816
+ .lines()
817
+ .find(|line| line.contains("node-b"))
818
+ .unwrap_or("");
819
+ let calls = logged_probe_calls(&log);
820
+ assert_eq!(calls.len(), 2, "one native query per status invocation");
821
+ assert!(
822
+ human
823
+ .matches(&format!("tmux -S {}", shell_quote_for_status_test(endpoint)))
824
+ .count()
825
+ == 1
826
+ && row_a.contains("gpt\\tα\\nmax")
827
+ && row_a.contains("xhigh\\nhigh")
828
+ && row_a.contains("pi\\tα\\nchannel")
829
+ && row_a.contains("win '甲")
830
+ && row_a.contains("%51")
831
+ && row_b.contains("grok\\tβ\\nxhigh")
832
+ && row_b.contains("high\\tmax\\nlow")
833
+ && row_a.matches("node-a").count() == 1
834
+ && row_b.matches("node-b").count() == 1,
835
+ "human fields must be escaped without splitting node rows and the shared template must quote its context:\n{human}"
836
+ );
837
+ }
838
+
215
839
  #[test]
216
840
  #[serial(status_brief)]
217
841
  fn cmd_status_is_readonly_on_legacy_and_multi_team_state() {