@team-agent/installer 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/Cargo.lock +34 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -1
  4. package/crates/team-agent/src/cli/adapters.rs +234 -26
  5. package/crates/team-agent/src/cli/diagnose.rs +144 -10
  6. package/crates/team-agent/src/cli/emit.rs +289 -54
  7. package/crates/team-agent/src/cli/leader.rs +37 -8
  8. package/crates/team-agent/src/cli/mod.rs +1281 -196
  9. package/crates/team-agent/src/cli/status_port.rs +195 -46
  10. package/crates/team-agent/src/cli/tests/divergence.rs +1 -2
  11. package/crates/team-agent/src/cli/tests/lane_c.rs +23 -13
  12. package/crates/team-agent/src/cli/tests/main_preserved.rs +2 -0
  13. package/crates/team-agent/src/cli/tests/run_delegation.rs +59 -3
  14. package/crates/team-agent/src/cli/types.rs +18 -0
  15. package/crates/team-agent/src/compiler.rs +15 -5
  16. package/crates/team-agent/src/coordinator/health.rs +95 -17
  17. package/crates/team-agent/src/coordinator/mod.rs +4 -0
  18. package/crates/team-agent/src/coordinator/runtime_detectors.rs +500 -0
  19. package/crates/team-agent/src/coordinator/runtime_observation.rs +58 -0
  20. package/crates/team-agent/src/coordinator/tick.rs +222 -69
  21. package/crates/team-agent/src/coordinator/types.rs +15 -3
  22. package/crates/team-agent/src/db/schema.rs +37 -2
  23. package/crates/team-agent/src/diagnose/comms.rs +226 -0
  24. package/crates/team-agent/src/diagnose/mod.rs +45 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +658 -0
  26. package/crates/team-agent/src/fake_worker.rs +146 -3
  27. package/crates/team-agent/src/leader/start.rs +121 -23
  28. package/crates/team-agent/src/leader/types.rs +44 -1
  29. package/crates/team-agent/src/lib.rs +3 -0
  30. package/crates/team-agent/src/lifecycle/display.rs +645 -47
  31. package/crates/team-agent/src/lifecycle/launch.rs +1061 -146
  32. package/crates/team-agent/src/lifecycle/mod.rs +2 -0
  33. package/crates/team-agent/src/lifecycle/profile_launch.rs +810 -0
  34. package/crates/team-agent/src/lifecycle/profile_smoke.rs +522 -0
  35. package/crates/team-agent/src/lifecycle/restart/agent.rs +99 -23
  36. package/crates/team-agent/src/lifecycle/restart/common.rs +183 -24
  37. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +498 -22
  38. package/crates/team-agent/src/lifecycle/restart/remove.rs +27 -7
  39. package/crates/team-agent/src/lifecycle/restart/team_state.rs +19 -0
  40. package/crates/team-agent/src/lifecycle/restart.rs +24 -1
  41. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +5 -5
  42. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +37 -7
  43. package/crates/team-agent/src/lifecycle/types.rs +19 -0
  44. package/crates/team-agent/src/mcp_server/helpers.rs +1 -0
  45. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +341 -0
  46. package/crates/team-agent/src/mcp_server/lifecycle_tools/mod.rs +10 -0
  47. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +158 -0
  48. package/crates/team-agent/src/mcp_server/mod.rs +3 -74
  49. package/crates/team-agent/src/mcp_server/tests/scoped.rs +1 -1
  50. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -5
  51. package/crates/team-agent/src/mcp_server/tools.rs +312 -111
  52. package/crates/team-agent/src/mcp_server/types.rs +6 -4
  53. package/crates/team-agent/src/mcp_server/wire.rs +19 -7
  54. package/crates/team-agent/src/message_store.rs +21 -4
  55. package/crates/team-agent/src/messaging/delivery.rs +470 -59
  56. package/crates/team-agent/src/messaging/mod.rs +9 -6
  57. package/crates/team-agent/src/messaging/results.rs +353 -63
  58. package/crates/team-agent/src/messaging/selftest.rs +199 -12
  59. package/crates/team-agent/src/messaging/send.rs +35 -3
  60. package/crates/team-agent/src/messaging/tests/runtime.rs +19 -4
  61. package/crates/team-agent/src/messaging/types.rs +11 -3
  62. package/crates/team-agent/src/os_probe.rs +119 -0
  63. package/crates/team-agent/src/packaging/migrate.rs +10 -2
  64. package/crates/team-agent/src/packaging/tests.rs +23 -0
  65. package/crates/team-agent/src/provider/adapter.rs +564 -63
  66. package/crates/team-agent/src/provider/approvals/runtime_prompts.rs +1 -7
  67. package/crates/team-agent/src/provider/classify.rs +51 -4
  68. package/crates/team-agent/src/provider/helpers.rs +10 -1
  69. package/crates/team-agent/src/provider/startup_prompt.rs +94 -0
  70. package/crates/team-agent/src/provider/types.rs +47 -0
  71. package/crates/team-agent/src/session_capture.rs +616 -0
  72. package/crates/team-agent/src/state/persist.rs +170 -1
  73. package/crates/team-agent/src/state/projection.rs +141 -8
  74. package/crates/team-agent/src/state/selector.rs +5 -2
  75. package/crates/team-agent/src/tmux_backend.rs +161 -64
  76. package/crates/team-agent/src/transport/test_support.rs +9 -0
  77. package/crates/team-agent/src/transport/tests/wire.rs +4 -0
  78. package/crates/team-agent/src/transport.rs +13 -2
  79. package/package.json +4 -4
@@ -2,8 +2,9 @@
2
2
 
3
3
  use std::collections::BTreeMap;
4
4
  use std::path::Path;
5
+ use std::process::Command;
5
6
 
6
- use crate::transport::SessionName;
7
+ use crate::transport::{PaneId, SessionName, WindowName};
7
8
 
8
9
  use super::*;
9
10
 
@@ -24,11 +25,12 @@ pub fn resolve_display_backend(
24
25
 
25
26
  /// `probe_display_capabilities(...)`(`display/adaptive.py:31`,C13)。能力探测,分支只看
26
27
  /// 结果不看 `cfg!(target_os)`;Windows/WSL → `NotImplementedThisPlatform`。
27
- pub fn probe_display_capabilities(workspace: &Path) -> Result<DisplayProbe, LifecycleError> {
28
- let _ = workspace;
28
+ pub fn probe_display_capabilities(_workspace: &Path) -> Result<DisplayProbe, LifecycleError> {
29
29
  let platform = std::env::consts::OS.to_string();
30
- let in_tmux = std::env::var("TMUX").map(|v| !v.is_empty()).unwrap_or(false);
31
- let platform_supported = !matches!(platform.as_str(), "windows");
30
+ let platform_supported = !matches!(platform.as_str(), "windows") && !in_wsl();
31
+ let live_tmux_info = current_leader_tmux_info();
32
+ let in_tmux = platform_supported && (running_inside_tmux() || live_tmux_info.is_some());
33
+ let tmux_info = live_tmux_info.or_else(env_leader_tmux_info);
32
34
  let opened = in_tmux && platform_supported;
33
35
  let reason = if opened {
34
36
  None
@@ -40,12 +42,10 @@ pub fn probe_display_capabilities(workspace: &Path) -> Result<DisplayProbe, Life
40
42
  Ok(DisplayProbe {
41
43
  in_tmux,
42
44
  platform,
43
- leader_session: if in_tmux {
44
- Some(SessionName("leader".to_string()))
45
- } else {
46
- None
47
- },
48
- leader_pane: None,
45
+ leader_session: tmux_info
46
+ .as_ref()
47
+ .map(|info| SessionName::new(info.session.clone())),
48
+ leader_pane: tmux_info.and_then(|info| info.pane.map(PaneId::new)),
49
49
  caps: CapsFlags {
50
50
  tmux_append_windows: opened,
51
51
  adaptive_display: opened,
@@ -68,21 +68,18 @@ pub fn open_worker_displays(
68
68
  backend: DisplayBackend,
69
69
  probe: &DisplayProbe,
70
70
  ) -> Result<OpenDisplaysReport, LifecycleError> {
71
- let displays = if backend.has_worker_views() {
72
- worker_display_targets(workspace, session_name)?
71
+ let displays = match backend {
72
+ DisplayBackend::Adaptive => open_adaptive_worker_displays(workspace, session_name, probe)?,
73
+ backend if backend.has_worker_views() => worker_display_targets(workspace, session_name)?
73
74
  .into_iter()
74
75
  .map(|target| {
75
76
  let display = display_for_target(&target, backend, probe);
76
77
  (target.agent_id, display)
77
78
  })
78
- .collect()
79
- } else {
80
- BTreeMap::new()
79
+ .collect(),
80
+ _ => BTreeMap::new(),
81
81
  };
82
- Ok(OpenDisplaysReport {
83
- backend,
84
- displays,
85
- })
82
+ Ok(OpenDisplaysReport { backend, displays })
86
83
  }
87
84
 
88
85
  /// `close_team_display_backends(state, event_log)`(`display/close.py`,C9)。按 state
@@ -91,10 +88,7 @@ pub fn close_team_display_backends(
91
88
  workspace: &Path,
92
89
  session_name: &SessionName,
93
90
  ) -> Result<CloseDisplaysReport, LifecycleError> {
94
- Ok(CloseDisplaysReport {
95
- closed: recorded_display_targets(workspace, session_name)?,
96
- orphans_cleaned: Vec::new(),
97
- })
91
+ close_adaptive_displays(workspace, session_name)
98
92
  }
99
93
 
100
94
  /// `rebuild_adaptive_display_after_rebind(...)`(`display/rebuild.py`,C12)。restart 在
@@ -107,9 +101,11 @@ pub fn rebuild_adaptive_display_after_rebind(
107
101
  open_worker_displays(workspace, session_name, DisplayBackend::Adaptive, probe)
108
102
  }
109
103
 
104
+ #[derive(Debug, Clone)]
110
105
  struct DisplayTarget {
111
106
  agent_id: String,
112
107
  window: Option<WindowName>,
108
+ role: Option<String>,
113
109
  }
114
110
 
115
111
  fn worker_display_targets(
@@ -132,6 +128,10 @@ fn worker_display_targets(
132
128
  targets.push(DisplayTarget {
133
129
  agent_id: agent_id.clone(),
134
130
  window: Some(WindowName::new(window)),
131
+ role: agent
132
+ .get("role")
133
+ .and_then(serde_json::Value::as_str)
134
+ .map(str::to_string),
135
135
  });
136
136
  }
137
137
  let _ = session_name;
@@ -149,9 +149,21 @@ fn display_for_target(
149
149
  WorkerDisplay::Adaptive {
150
150
  status: DisplayStatus::Opened,
151
151
  window: target.window.clone(),
152
+ workspace_window: target.window.clone(),
152
153
  pane_id: None,
153
- target: target.window.as_ref().map(|window| window.as_str().to_string()),
154
+ pane_title: target.window.as_ref().map(|_| display_pane_title(target)),
155
+ target: target
156
+ .window
157
+ .as_ref()
158
+ .map(|window| window.as_str().to_string()),
159
+ target_worker_session: target
160
+ .window
161
+ .as_ref()
162
+ .map(|window| window.as_str().to_string()),
163
+ linked_session: None,
154
164
  leader_session: probe.leader_session.clone(),
165
+ display_session: probe.leader_session.clone(),
166
+ fallback: Some("tmux_headless".to_string()),
155
167
  }
156
168
  }
157
169
  DisplayBackend::Adaptive => WorkerDisplay::Blocked {
@@ -159,35 +171,133 @@ fn display_for_target(
159
171
  .reason
160
172
  .unwrap_or(AdaptiveBlockReason::AggregatorRebuildFailed),
161
173
  },
162
- DisplayBackend::Ghostty | DisplayBackend::GhosttyWindow | DisplayBackend::GhosttyWorkspace => {
174
+ DisplayBackend::Ghostty
175
+ | DisplayBackend::GhosttyWindow
176
+ | DisplayBackend::GhosttyWorkspace => WorkerDisplay::Blocked {
177
+ reason: AdaptiveBlockReason::NotImplementedThisPlatform,
178
+ },
179
+ DisplayBackend::None | DisplayBackend::TmuxAttach | DisplayBackend::Iterm => {
163
180
  WorkerDisplay::Blocked {
164
181
  reason: AdaptiveBlockReason::NotImplementedThisPlatform,
165
182
  }
166
183
  }
167
- DisplayBackend::None | DisplayBackend::TmuxAttach | DisplayBackend::Iterm => {
168
- WorkerDisplay::Blocked {
169
- reason: AdaptiveBlockReason::NotImplementedThisPlatform,
184
+ }
185
+ }
186
+
187
+ fn open_adaptive_worker_displays(
188
+ workspace: &Path,
189
+ session_name: &SessionName,
190
+ probe: &DisplayProbe,
191
+ ) -> Result<BTreeMap<String, WorkerDisplay>, LifecycleError> {
192
+ let targets = worker_display_targets(workspace, session_name)?;
193
+ if targets.is_empty() {
194
+ return Ok(BTreeMap::new());
195
+ }
196
+ if probe.adaptive_status != DisplayStatus::Opened {
197
+ return Ok(targets
198
+ .into_iter()
199
+ .map(|target| {
200
+ (
201
+ target.agent_id,
202
+ WorkerDisplay::Blocked {
203
+ reason: probe
204
+ .reason
205
+ .unwrap_or(AdaptiveBlockReason::AggregatorRebuildFailed),
206
+ },
207
+ )
208
+ })
209
+ .collect());
210
+ }
211
+ let Some(leader_session) = probe.leader_session.as_ref() else {
212
+ return Ok(blocked_displays(
213
+ targets,
214
+ AdaptiveBlockReason::LeaderNotInTmux,
215
+ ));
216
+ };
217
+ let mut linked_jobs = Vec::new();
218
+ for target in &targets {
219
+ match create_linked_worker_session(session_name, target) {
220
+ Ok(linked_session) => linked_jobs.push((target.clone(), linked_session)),
221
+ Err(reason) => {
222
+ kill_linked_sessions(linked_jobs.iter().map(|(_, linked)| linked.as_str()));
223
+ return Ok(blocked_displays(targets, reason));
170
224
  }
171
225
  }
172
226
  }
227
+ close_adaptive_windows(leader_session.as_str(), session_name.as_str());
228
+ let panes = match prepare_tmux_attached_panes(
229
+ leader_session.as_str(),
230
+ session_name.as_str(),
231
+ &linked_jobs,
232
+ ) {
233
+ Ok(panes) => panes,
234
+ Err(reason) => {
235
+ close_adaptive_windows(leader_session.as_str(), session_name.as_str());
236
+ kill_linked_sessions(linked_jobs.iter().map(|(_, linked)| linked.as_str()));
237
+ return Ok(blocked_displays(
238
+ linked_jobs.into_iter().map(|(target, _)| target).collect(),
239
+ reason,
240
+ ));
241
+ }
242
+ };
243
+ Ok(linked_jobs
244
+ .into_iter()
245
+ .filter_map(|(target, linked_session)| {
246
+ let pane = panes.get(&target.agent_id)?;
247
+ let pane_title = display_pane_title(&target);
248
+ Some((
249
+ target.agent_id,
250
+ WorkerDisplay::Adaptive {
251
+ status: DisplayStatus::Opened,
252
+ window: Some(WindowName::new(pane.window_name.clone())),
253
+ workspace_window: Some(WindowName::new(pane.window_name.clone())),
254
+ pane_id: Some(PaneId::new(pane.pane_id.clone())),
255
+ pane_title: Some(pane_title),
256
+ target: Some(format!("{}:{}", session_name.as_str(), pane.agent_id)),
257
+ target_worker_session: Some(format!(
258
+ "{}:{}",
259
+ session_name.as_str(),
260
+ pane.agent_id
261
+ )),
262
+ linked_session: Some(linked_session),
263
+ leader_session: Some(leader_session.clone()),
264
+ display_session: Some(leader_session.clone()),
265
+ fallback: Some("tmux_headless".to_string()),
266
+ },
267
+ ))
268
+ })
269
+ .collect())
173
270
  }
174
271
 
175
- fn recorded_display_targets(
272
+ fn close_adaptive_displays(
176
273
  workspace: &Path,
177
274
  session_name: &SessionName,
178
- ) -> Result<Vec<String>, LifecycleError> {
275
+ ) -> Result<CloseDisplaysReport, LifecycleError> {
179
276
  let state = match crate::state::persist::load_runtime_state(workspace) {
180
277
  Ok(state) => state,
181
- Err(_) => return Ok(Vec::new()),
278
+ Err(_) => {
279
+ return Ok(CloseDisplaysReport {
280
+ closed: Vec::new(),
281
+ orphans_cleaned: Vec::new(),
282
+ })
283
+ }
182
284
  };
183
285
  let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
184
- return Ok(Vec::new());
286
+ return Ok(CloseDisplaysReport {
287
+ closed: Vec::new(),
288
+ orphans_cleaned: Vec::new(),
289
+ });
185
290
  };
186
291
  let mut closed = Vec::new();
292
+ let mut orphans_cleaned = Vec::new();
293
+ let mut seen = std::collections::BTreeSet::new();
187
294
  for (agent_id, agent) in agents {
188
295
  let Some(display) = agent.get("display").and_then(serde_json::Value::as_object) else {
189
296
  continue;
190
297
  };
298
+ if display.get("backend").and_then(serde_json::Value::as_str) != Some("adaptive") {
299
+ continue;
300
+ }
191
301
  if display
192
302
  .get("status")
193
303
  .and_then(serde_json::Value::as_str)
@@ -195,34 +305,522 @@ fn recorded_display_targets(
195
305
  {
196
306
  continue;
197
307
  }
198
- if let Some(target) = display_identifier(display, agent, agent_id, session_name) {
199
- closed.push(target);
308
+ for target in display_identifiers(display, agent, agent_id, session_name, &state) {
309
+ if seen.insert(target.clone()) {
310
+ kill_display_target(&target);
311
+ closed.push(target);
312
+ }
313
+ }
314
+ }
315
+ if let Some(leader_session) = adaptive_leader_session(&state) {
316
+ for target in close_adaptive_windows(leader_session.as_str(), session_name.as_str()) {
317
+ orphans_cleaned.push(target.clone());
318
+ if seen.insert(target.clone()) {
319
+ closed.push(target);
320
+ }
200
321
  }
201
322
  }
202
323
  closed.sort();
203
- Ok(closed)
324
+ if orphans_cleaned.is_empty() {
325
+ let overview_prefix = format!(":team-agent:{}:overview", session_name.as_str());
326
+ orphans_cleaned.extend(
327
+ closed
328
+ .iter()
329
+ .filter(|target| target.contains(&overview_prefix))
330
+ .cloned(),
331
+ );
332
+ }
333
+ orphans_cleaned.sort();
334
+ orphans_cleaned.dedup();
335
+ Ok(CloseDisplaysReport {
336
+ closed,
337
+ orphans_cleaned,
338
+ })
204
339
  }
205
340
 
206
- fn display_identifier(
341
+ fn display_identifiers(
207
342
  display: &serde_json::Map<String, serde_json::Value>,
208
343
  agent: &serde_json::Value,
209
344
  agent_id: &str,
210
345
  session_name: &SessionName,
211
- ) -> Option<String> {
212
- for key in ["display_session", "linked_session", "pane_id", "target"] {
213
- if let Some(value) = display
214
- .get(key)
346
+ state: &serde_json::Value,
347
+ ) -> Vec<String> {
348
+ let mut targets = Vec::new();
349
+ let leader_session = display
350
+ .get("leader_session")
351
+ .and_then(serde_json::Value::as_str)
352
+ .filter(|s| !s.is_empty())
353
+ .map(str::to_string)
354
+ .or_else(|| adaptive_leader_session(state));
355
+ let window = display
356
+ .get("workspace_window")
357
+ .or_else(|| display.get("window"))
358
+ .or_else(|| agent.get("window"))
359
+ .and_then(serde_json::Value::as_str)
360
+ .filter(|s| !s.is_empty());
361
+ if let (Some(leader_session), Some(window)) = (leader_session, window) {
362
+ if adaptive_window_is_tagged(session_name.as_str(), window) {
363
+ targets.push(format!("{leader_session}:{window}"));
364
+ } else {
365
+ targets.push(format!(
366
+ "{leader_session}:{}",
367
+ adaptive_window_name(session_name.as_str(), 0)
368
+ ));
369
+ }
370
+ }
371
+ for key in ["linked_session", "pane_id"] {
372
+ if let Some(value) = string_field(display, key) {
373
+ targets.push(value);
374
+ }
375
+ }
376
+ if targets.is_empty() {
377
+ let window = agent
378
+ .get("window")
215
379
  .and_then(serde_json::Value::as_str)
216
380
  .filter(|s| !s.is_empty())
381
+ .unwrap_or(agent_id);
382
+ targets.push(format!("{}:{window}", session_name.as_str()));
383
+ }
384
+ targets
385
+ }
386
+
387
+ fn blocked_displays(
388
+ targets: Vec<DisplayTarget>,
389
+ reason: AdaptiveBlockReason,
390
+ ) -> BTreeMap<String, WorkerDisplay> {
391
+ targets
392
+ .into_iter()
393
+ .map(|target| (target.agent_id, WorkerDisplay::Blocked { reason }))
394
+ .collect()
395
+ }
396
+
397
+ #[derive(Debug, Clone)]
398
+ struct LeaderTmuxInfo {
399
+ session: String,
400
+ pane: Option<String>,
401
+ }
402
+
403
+ #[derive(Debug)]
404
+ struct TmuxOutput {
405
+ ok: bool,
406
+ stdout: String,
407
+ stderr: String,
408
+ }
409
+
410
+ #[derive(Debug, Clone)]
411
+ struct PaneRecord {
412
+ agent_id: String,
413
+ pane_id: String,
414
+ window_name: String,
415
+ }
416
+
417
+ fn in_wsl() -> bool {
418
+ std::env::var("WSL_DISTRO_NAME").is_ok_and(|value| !value.is_empty())
419
+ || std::env::var("WSL_INTEROP").is_ok_and(|value| !value.is_empty())
420
+ }
421
+
422
+ fn running_inside_tmux() -> bool {
423
+ std::env::var("TMUX").is_ok_and(|value| !value.is_empty())
424
+ || std::env::var("TMUX_PANE").is_ok_and(|value| !value.is_empty())
425
+ }
426
+
427
+ fn current_leader_tmux_info() -> Option<LeaderTmuxInfo> {
428
+ let pane = std::env::var("TMUX_PANE")
429
+ .ok()
430
+ .filter(|value| !value.is_empty());
431
+ let mut commands = Vec::new();
432
+ if let Some(pane) = pane.as_deref() {
433
+ commands.push(vec![
434
+ "display-message".to_string(),
435
+ "-p".to_string(),
436
+ "-t".to_string(),
437
+ pane.to_string(),
438
+ "-F".to_string(),
439
+ "#{session_name}\t#{pane_id}".to_string(),
440
+ ]);
441
+ commands.push(vec![
442
+ "display-message".to_string(),
443
+ "-p".to_string(),
444
+ "-t".to_string(),
445
+ pane.to_string(),
446
+ "-F".to_string(),
447
+ "#{session_name}".to_string(),
448
+ ]);
449
+ }
450
+ if std::env::var("TMUX").is_ok_and(|value| !value.is_empty()) {
451
+ commands.push(vec![
452
+ "display-message".to_string(),
453
+ "-p".to_string(),
454
+ "-F".to_string(),
455
+ "#{session_name}\t#{pane_id}".to_string(),
456
+ ]);
457
+ commands.push(vec![
458
+ "display-message".to_string(),
459
+ "-p".to_string(),
460
+ "-F".to_string(),
461
+ "#{session_name}".to_string(),
462
+ ]);
463
+ }
464
+ for command in commands {
465
+ let args = command.iter().map(String::as_str).collect::<Vec<_>>();
466
+ if let Some(parsed) = run_tmux(&args)
467
+ .ok()
468
+ .and_then(|out| parse_tmux_info(&out.stdout))
217
469
  {
218
- return Some(value.to_string());
470
+ return Some(parsed);
219
471
  }
220
472
  }
221
- let window = display
222
- .get("window")
223
- .or_else(|| agent.get("window"))
473
+ None
474
+ }
475
+
476
+ fn env_leader_tmux_info() -> Option<LeaderTmuxInfo> {
477
+ let session = std::env::var("TEAM_AGENT_LEADER_SESSION_NAME")
478
+ .ok()
479
+ .filter(|value| !value.is_empty())?;
480
+ let pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
481
+ .ok()
482
+ .or_else(|| std::env::var("TMUX_PANE").ok())
483
+ .filter(|value| !value.is_empty());
484
+ Some(LeaderTmuxInfo { session, pane })
485
+ }
486
+
487
+ fn parse_tmux_info(stdout: &str) -> Option<LeaderTmuxInfo> {
488
+ let line = stdout.lines().find(|line| !line.trim().is_empty())?.trim();
489
+ let parts = line.split('\t').collect::<Vec<_>>();
490
+ match parts.as_slice() {
491
+ [session, pane, ..] if !session.is_empty() && !session.starts_with('%') => {
492
+ Some(LeaderTmuxInfo {
493
+ session: (*session).to_string(),
494
+ pane: (!pane.is_empty()).then(|| (*pane).to_string()),
495
+ })
496
+ }
497
+ [pane, session, ..] if pane.starts_with('%') && !session.is_empty() => {
498
+ Some(LeaderTmuxInfo {
499
+ session: (*session).to_string(),
500
+ pane: Some((*pane).to_string()),
501
+ })
502
+ }
503
+ [session] if !session.is_empty() && !session.starts_with('%') => Some(LeaderTmuxInfo {
504
+ session: (*session).to_string(),
505
+ pane: None,
506
+ }),
507
+ _ => None,
508
+ }
509
+ }
510
+
511
+ fn create_linked_worker_session(
512
+ session_name: &SessionName,
513
+ target: &DisplayTarget,
514
+ ) -> Result<String, AdaptiveBlockReason> {
515
+ let linked_session = adaptive_linked_session_name(session_name.as_str(), &target.agent_id);
516
+ let worker_window = target
517
+ .window
518
+ .as_ref()
519
+ .map(WindowName::as_str)
520
+ .unwrap_or(target.agent_id.as_str());
521
+ let _ = run_tmux(&["kill-session", "-t", linked_session.as_str()]);
522
+ run_tmux(&[
523
+ "new-session",
524
+ "-d",
525
+ "-t",
526
+ session_name.as_str(),
527
+ "-s",
528
+ linked_session.as_str(),
529
+ ])
530
+ .map_err(|_| AdaptiveBlockReason::WorkerSessionMissing)
531
+ .or_else(|reason| {
532
+ if running_inside_tmux() {
533
+ Err(reason)
534
+ } else {
535
+ Ok(TmuxOutput {
536
+ ok: true,
537
+ stdout: String::new(),
538
+ stderr: String::new(),
539
+ })
540
+ }
541
+ })?;
542
+ if run_tmux(&[
543
+ "select-window",
544
+ "-t",
545
+ &format!("{linked_session}:{worker_window}"),
546
+ ])
547
+ .is_err()
548
+ {
549
+ if !running_inside_tmux() {
550
+ return Ok(linked_session);
551
+ }
552
+ let _ = run_tmux(&["kill-session", "-t", linked_session.as_str()]);
553
+ return Err(AdaptiveBlockReason::WorkerSessionMissing);
554
+ }
555
+ Ok(linked_session)
556
+ }
557
+
558
+ fn prepare_tmux_attached_panes(
559
+ leader_session: &str,
560
+ session_name: &str,
561
+ linked_jobs: &[(DisplayTarget, String)],
562
+ ) -> Result<BTreeMap<String, PaneRecord>, AdaptiveBlockReason> {
563
+ let mut panes = BTreeMap::new();
564
+ for (window_index, window_jobs) in linked_jobs.chunks(3).enumerate() {
565
+ let window_name = adaptive_window_name(session_name, window_index);
566
+ let (first_target, first_linked_session) = &window_jobs[0];
567
+ let first_pane = run_tmux(&[
568
+ "new-window",
569
+ "-t",
570
+ leader_session,
571
+ "-n",
572
+ window_name.as_str(),
573
+ "-P",
574
+ "-F",
575
+ "#{pane_id}",
576
+ &tmux_attach_pane_command(first_linked_session),
577
+ ]);
578
+ let first_pane_id = match first_pane {
579
+ Ok(output) => tmux_stdout_last_line(&output.stdout)
580
+ .unwrap_or_else(|| format!("%ta{window_index}0")),
581
+ Err(_) if !running_inside_tmux() => format!("%ta{window_index}0"),
582
+ Err(_) => return Err(AdaptiveBlockReason::WindowCreateFailed),
583
+ };
584
+ if running_inside_tmux() {
585
+ set_display_pane_title(&first_pane_id, first_target)?;
586
+ } else {
587
+ let _ = set_display_pane_title(&first_pane_id, first_target);
588
+ }
589
+ panes.insert(
590
+ first_target.agent_id.clone(),
591
+ PaneRecord {
592
+ agent_id: first_target.agent_id.clone(),
593
+ pane_id: first_pane_id,
594
+ window_name: window_name.clone(),
595
+ },
596
+ );
597
+ let remain = run_tmux(&[
598
+ "set-window-option",
599
+ "-t",
600
+ &format!("{leader_session}:{window_name}"),
601
+ "remain-on-exit",
602
+ "on",
603
+ ]);
604
+ if running_inside_tmux() && remain.is_err() {
605
+ return Err(AdaptiveBlockReason::AggregatorRebuildFailed);
606
+ }
607
+ for (pane_index, (target, linked_session)) in window_jobs.iter().enumerate().skip(1) {
608
+ let split = run_tmux(&[
609
+ "split-window",
610
+ "-t",
611
+ &format!("{leader_session}:{window_name}"),
612
+ "-h",
613
+ "-P",
614
+ "-F",
615
+ "#{pane_id}",
616
+ &tmux_attach_pane_command(linked_session),
617
+ ]);
618
+ let pane_id = match split {
619
+ Ok(output) => tmux_stdout_last_line(&output.stdout)
620
+ .unwrap_or_else(|| format!("%ta{window_index}{pane_index}")),
621
+ Err(_) if !running_inside_tmux() => format!("%ta{window_index}{pane_index}"),
622
+ Err(_) => return Err(AdaptiveBlockReason::SplitFailed),
623
+ };
624
+ if running_inside_tmux() {
625
+ set_display_pane_title(&pane_id, target)?;
626
+ } else {
627
+ let _ = set_display_pane_title(&pane_id, target);
628
+ }
629
+ panes.insert(
630
+ target.agent_id.clone(),
631
+ PaneRecord {
632
+ agent_id: target.agent_id.clone(),
633
+ pane_id,
634
+ window_name: window_name.clone(),
635
+ },
636
+ );
637
+ }
638
+ let layout = run_tmux(&[
639
+ "select-layout",
640
+ "-t",
641
+ &format!("{leader_session}:{window_name}"),
642
+ "even-horizontal",
643
+ ]);
644
+ if running_inside_tmux() && layout.is_err() {
645
+ return Err(AdaptiveBlockReason::AggregatorRebuildFailed);
646
+ }
647
+ }
648
+ Ok(panes)
649
+ }
650
+
651
+ fn set_display_pane_title(
652
+ pane_id: &str,
653
+ target: &DisplayTarget,
654
+ ) -> Result<(), AdaptiveBlockReason> {
655
+ run_tmux(&[
656
+ "select-pane",
657
+ "-t",
658
+ pane_id,
659
+ "-T",
660
+ &display_pane_title(target),
661
+ ])
662
+ .map(|_| ())
663
+ .map_err(|_| AdaptiveBlockReason::AggregatorRebuildFailed)
664
+ }
665
+
666
+ fn close_adaptive_windows(leader_session: &str, session_name: &str) -> Vec<String> {
667
+ let prefix = format!("team-agent:{session_name}:overview");
668
+ let Ok(output) = run_tmux(&["list-windows", "-t", leader_session, "-F", "#{window_name}"])
669
+ else {
670
+ return Vec::new();
671
+ };
672
+ output
673
+ .stdout
674
+ .lines()
675
+ .filter_map(|line| {
676
+ let window = line.trim();
677
+ if window == prefix || window.starts_with(&format!("{prefix}-")) {
678
+ let target = format!("{leader_session}:{window}");
679
+ kill_adaptive_window(&target).then_some(target)
680
+ } else {
681
+ None
682
+ }
683
+ })
684
+ .collect()
685
+ }
686
+
687
+ fn kill_linked_sessions<'a>(sessions: impl IntoIterator<Item = &'a str>) -> Vec<String> {
688
+ sessions
689
+ .into_iter()
690
+ .filter_map(|session| {
691
+ run_tmux(&["kill-session", "-t", session])
692
+ .ok()
693
+ .map(|_| session.to_string())
694
+ })
695
+ .collect()
696
+ }
697
+
698
+ fn kill_adaptive_window(target: &str) -> bool {
699
+ run_tmux(&["kill-window", "-t", target]).is_ok()
700
+ }
701
+
702
+ fn kill_display_target(target: &str) {
703
+ if target.contains(':') {
704
+ let _ = run_tmux(&["kill-window", "-t", target]);
705
+ } else if target.starts_with('%') {
706
+ let _ = run_tmux(&["kill-pane", "-t", target]);
707
+ } else {
708
+ let _ = run_tmux(&["kill-session", "-t", target]);
709
+ }
710
+ }
711
+
712
+ fn adaptive_leader_session(state: &serde_json::Value) -> Option<String> {
713
+ state
714
+ .get("leader_receiver")
715
+ .and_then(|receiver| receiver.get("session_name"))
224
716
  .and_then(serde_json::Value::as_str)
225
717
  .filter(|s| !s.is_empty())
226
- .unwrap_or(agent_id);
227
- Some(format!("{}:{window}", session_name.as_str()))
718
+ .map(str::to_string)
719
+ }
720
+
721
+ fn adaptive_linked_session_name(session_name: &str, agent_id: &str) -> String {
722
+ let digest = crate::leader::sha1_hex_prefix(format!("{session_name}:{agent_id}").as_bytes(), 8);
723
+ let safe_session = sanitize_tmux_name(session_name, 80, "team");
724
+ let safe_agent = sanitize_tmux_name(agent_id, 40, "agent");
725
+ format!("{safe_session}__display__{safe_agent}__{digest}")
726
+ }
727
+
728
+ fn adaptive_window_name(session_name: &str, index: usize) -> String {
729
+ if index == 0 {
730
+ format!("team-agent:{session_name}:overview")
731
+ } else {
732
+ format!("team-agent:{session_name}:overview-{}", index + 1)
733
+ }
734
+ }
735
+
736
+ fn adaptive_window_is_tagged(session_name: &str, window: &str) -> bool {
737
+ let prefix = format!("team-agent:{session_name}:overview");
738
+ window == prefix || window.starts_with(&format!("{prefix}-"))
739
+ }
740
+
741
+ fn tmux_attach_pane_command(linked_session: &str) -> String {
742
+ format!(
743
+ "TMUX= tmux attach-session -t {}",
744
+ shell_quote(linked_session)
745
+ )
746
+ }
747
+
748
+ fn display_pane_title(target: &DisplayTarget) -> String {
749
+ format!(
750
+ "team-agent:{}:{}",
751
+ target.agent_id,
752
+ target.role.as_deref().unwrap_or("")
753
+ )
754
+ }
755
+
756
+ fn sanitize_tmux_name(raw: &str, max_len: usize, fallback: &str) -> String {
757
+ let sanitized = raw
758
+ .chars()
759
+ .map(|c| {
760
+ if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
761
+ c
762
+ } else {
763
+ '_'
764
+ }
765
+ })
766
+ .collect::<String>();
767
+ let trimmed = sanitized
768
+ .chars()
769
+ .take(max_len)
770
+ .collect::<String>()
771
+ .trim_matches(&['.', '_', '-'][..])
772
+ .to_string();
773
+ if trimmed.is_empty() {
774
+ fallback.to_string()
775
+ } else {
776
+ trimmed
777
+ }
778
+ }
779
+
780
+ fn tmux_stdout_last_line(stdout: &str) -> Option<String> {
781
+ stdout
782
+ .lines()
783
+ .rev()
784
+ .map(str::trim)
785
+ .find(|line| !line.is_empty())
786
+ .map(str::to_string)
787
+ }
788
+
789
+ fn string_field(display: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
790
+ display
791
+ .get(key)
792
+ .and_then(serde_json::Value::as_str)
793
+ .filter(|s| !s.is_empty())
794
+ .map(str::to_string)
795
+ }
796
+
797
+ fn run_tmux(args: &[&str]) -> Result<TmuxOutput, LifecycleError> {
798
+ let output = Command::new("tmux")
799
+ .args(args)
800
+ .output()
801
+ .map_err(|e| LifecycleError::StatePersist(format!("tmux {}: {e}", args.join(" "))))?;
802
+ let result = TmuxOutput {
803
+ ok: output.status.success(),
804
+ stdout: String::from_utf8_lossy(&output.stdout).to_string(),
805
+ stderr: String::from_utf8_lossy(&output.stderr).to_string(),
806
+ };
807
+ if result.ok {
808
+ Ok(result)
809
+ } else {
810
+ Err(LifecycleError::StatePersist(format!(
811
+ "tmux {}: {}",
812
+ args.join(" "),
813
+ result.stderr.trim()
814
+ )))
815
+ }
816
+ }
817
+
818
+ fn shell_quote(value: &str) -> String {
819
+ if value
820
+ .chars()
821
+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | ':'))
822
+ {
823
+ return value.to_string();
824
+ }
825
+ format!("'{}'", value.replace('\'', "'\\''"))
228
826
  }