@team-agent/installer 0.3.13 → 0.3.15

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 (38) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/emit.rs +92 -11
  5. package/crates/team-agent/src/cli/mod.rs +228 -85
  6. package/crates/team-agent/src/cli/send.rs +252 -0
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +15 -3
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +159 -2
  9. package/crates/team-agent/src/cli/types.rs +30 -1
  10. package/crates/team-agent/src/compiler/tests.rs +2 -2
  11. package/crates/team-agent/src/compiler.rs +1 -1
  12. package/crates/team-agent/src/fake_worker.rs +32 -145
  13. package/crates/team-agent/src/leader/start.rs +279 -4
  14. package/crates/team-agent/src/lifecycle/display.rs +3 -3
  15. package/crates/team-agent/src/lifecycle/launch.rs +692 -92
  16. package/crates/team-agent/src/lifecycle/restart/agent.rs +137 -17
  17. package/crates/team-agent/src/lifecycle/restart/common.rs +88 -42
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +197 -33
  19. package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
  20. package/crates/team-agent/src/lifecycle/tests/core.rs +195 -50
  21. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +934 -72
  22. package/crates/team-agent/src/lifecycle/types.rs +5 -0
  23. package/crates/team-agent/src/lifecycle/worker_command_context.rs +8 -0
  24. package/crates/team-agent/src/mcp_server/wire.rs +153 -3
  25. package/crates/team-agent/src/messaging/leader_receiver.rs +276 -43
  26. package/crates/team-agent/src/messaging/mod.rs +6 -3
  27. package/crates/team-agent/src/messaging/results.rs +240 -158
  28. package/crates/team-agent/src/messaging/send.rs +3 -2
  29. package/crates/team-agent/src/messaging/tests/e23.rs +35 -0
  30. package/crates/team-agent/src/messaging/tests/mod.rs +6 -2
  31. package/crates/team-agent/src/messaging/tests/runtime.rs +9 -9
  32. package/crates/team-agent/src/os_probe.rs +11 -0
  33. package/crates/team-agent/src/state/persist.rs +6 -0
  34. package/crates/team-agent/src/tmux_backend.rs +90 -0
  35. package/crates/team-agent/src/transport/test_support.rs +46 -1
  36. package/crates/team-agent/src/transport.rs +31 -0
  37. package/package.json +4 -4
  38. package/skills/team-agent/references/recovery-runbook.md +277 -0
@@ -86,7 +86,14 @@ pub(crate) fn start_agent_at_paths(
86
86
  }
87
87
  let session_name = state_session_name(&state);
88
88
  let window = agent_window(&agent, agent_id);
89
- if !force && window_exists(transport, &session_name, &window) {
89
+ let adaptive_layout =
90
+ open_display && crate::lifecycle::launch::state_uses_adaptive_layout(&state);
91
+ let agent_live = if adaptive_layout {
92
+ agent_pane_live(transport, &agent)
93
+ } else {
94
+ window_exists(transport, &session_name, &window)
95
+ };
96
+ if !force && agent_live {
90
97
  mark_agent_running_noop(&mut state, agent_id, &session_name, &window)?;
91
98
  crate::state::projection::save_team_scoped_state(workspace, &state)
92
99
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
@@ -141,6 +148,28 @@ pub(crate) fn start_agent_at_paths(
141
148
  let into_existing_session =
142
149
  session_live_or_default(transport, &session_name, session_name_present(&state));
143
150
  let safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
151
+ let layout_placement = if adaptive_layout {
152
+ crate::lifecycle::launch::adaptive_existing_placement_for_agent(
153
+ &state,
154
+ transport,
155
+ &session_name,
156
+ agent_id,
157
+ )
158
+ .or_else(|| {
159
+ crate::lifecycle::launch::adaptive_placement_for_agent(
160
+ &state,
161
+ transport,
162
+ &session_name,
163
+ agent_id,
164
+ )
165
+ })
166
+ } else {
167
+ None
168
+ };
169
+ let spawn_window = layout_placement
170
+ .as_ref()
171
+ .map(|placement| placement.layout_window.as_str().to_string())
172
+ .unwrap_or_else(|| window.clone());
144
173
  let spawn = spawn_agent_window(
145
174
  workspace,
146
175
  &session_name,
@@ -150,9 +179,10 @@ pub(crate) fn start_agent_at_paths(
150
179
  into_existing_session,
151
180
  transport,
152
181
  Some(&safety),
182
+ layout_placement.as_ref(),
153
183
  None,
154
184
  )?;
155
- mark_agent_started(&mut state, agent_id, &window, &spawn, &safety)?;
185
+ mark_agent_started(&mut state, agent_id, &spawn_window, &spawn, &safety)?;
156
186
  crate::state::projection::save_team_scoped_state(workspace, &state)
157
187
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
158
188
  write_start_agent_start_event(
@@ -162,9 +192,9 @@ pub(crate) fn start_agent_at_paths(
162
192
  provider,
163
193
  start_mode,
164
194
  &session_name,
165
- &window,
195
+ &spawn_window,
166
196
  spawn_session_id,
167
- into_existing_session,
197
+ tmux_start_mode_for_spawn(&spawn, into_existing_session),
168
198
  )?;
169
199
  let coordinator_started = start_coordinator_for_workspace(workspace)?;
170
200
  Ok(StartAgentOutcome::Running {
@@ -180,6 +210,55 @@ pub(crate) fn start_agent_at_paths(
180
210
  })
181
211
  }
182
212
 
213
+ fn agent_pane_live(
214
+ transport: &dyn crate::transport::Transport,
215
+ agent: &serde_json::Value,
216
+ ) -> bool {
217
+ let Some(pane) = agent
218
+ .get("pane_id")
219
+ .and_then(serde_json::Value::as_str)
220
+ .filter(|pane| !pane.is_empty())
221
+ .map(crate::transport::PaneId::new)
222
+ else {
223
+ return false;
224
+ };
225
+ agent_pane_live_by_id(transport, &pane)
226
+ }
227
+
228
+ fn agent_pane_live_by_id(
229
+ transport: &dyn crate::transport::Transport,
230
+ pane: &crate::transport::PaneId,
231
+ ) -> bool {
232
+ match transport.has_pane(&pane) {
233
+ Ok(Some(live)) => live,
234
+ Ok(None) | Err(_) => !matches!(
235
+ transport.liveness(&pane),
236
+ Ok(crate::model::enums::PaneLiveness::Dead)
237
+ ),
238
+ }
239
+ }
240
+
241
+ fn tmux_start_mode_for_spawn(
242
+ spawn: &SpawnedAgentWindow,
243
+ into_existing_session: bool,
244
+ ) -> &'static str {
245
+ if let Some(placement) = spawn.layout_placement.as_ref() {
246
+ if placement.starts_window {
247
+ if into_existing_session {
248
+ "new-window"
249
+ } else {
250
+ "new-session"
251
+ }
252
+ } else {
253
+ "split-window"
254
+ }
255
+ } else if into_existing_session {
256
+ "new-window"
257
+ } else {
258
+ "new-session"
259
+ }
260
+ }
261
+
183
262
  /// `stop_agent(workspace, agent_id, team)`(`lifecycle/operations.py:62`)。
184
263
  /// owner-gate → kill window → **同时关显示** → 写 state。
185
264
  pub fn stop_agent(
@@ -233,16 +312,34 @@ pub(super) fn stop_agent_at_paths(
233
312
  .filter(|s| !s.is_empty())
234
313
  .unwrap_or_else(|| agent_id.as_str())
235
314
  .to_string();
236
- let target_str = format!("{}:{window}", session_name.as_str());
237
- let stopped = window_exists(transport, &session_name, &window);
315
+ let pane_id = state
316
+ .get("agents")
317
+ .and_then(|v| v.get(agent_id.as_str()))
318
+ .and_then(|v| v.get("pane_id"))
319
+ .and_then(|v| v.as_str())
320
+ .filter(|pane| !pane.is_empty())
321
+ .map(crate::transport::PaneId::new);
322
+ let target_str = pane_id
323
+ .as_ref()
324
+ .map(|pane| pane.as_str().to_string())
325
+ .unwrap_or_else(|| format!("{}:{window}", session_name.as_str()));
326
+ let stopped = pane_id
327
+ .as_ref()
328
+ .map(|pane| agent_pane_live_by_id(transport, pane))
329
+ .unwrap_or_else(|| window_exists(transport, &session_name, &window));
238
330
  if stopped {
239
- let target = Target::SessionWindow {
240
- session: session_name.clone(),
241
- window: WindowName::new(&window),
242
- };
243
331
  // golden operations.py:84-86: a non-zero kill-window raises
244
332
  // RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
245
- if let Err(e) = transport.kill_window(&target) {
333
+ let kill_result = if let Some(pane) = pane_id.as_ref() {
334
+ transport.kill_pane(pane)
335
+ } else {
336
+ let target = Target::SessionWindow {
337
+ session: session_name.clone(),
338
+ window: WindowName::new(&window),
339
+ };
340
+ transport.kill_window(&target)
341
+ };
342
+ if let Err(e) = kill_result {
246
343
  let stderr = match &e {
247
344
  crate::transport::TransportError::Subprocess { stderr, .. } => stderr.trim().to_string(),
248
345
  other => other.to_string(),
@@ -335,6 +432,34 @@ fn mark_agent_started(
335
432
  &spawn.profile_launch,
336
433
  );
337
434
  crate::lifecycle::launch::persist_effective_approval_policy(agent, safety);
435
+ if let Some(placement) = spawn.layout_placement.as_ref() {
436
+ agent.insert(
437
+ "layout_window".to_string(),
438
+ serde_json::json!(placement.layout_window.as_str()),
439
+ );
440
+ agent.insert(
441
+ "layout_index".to_string(),
442
+ serde_json::json!(placement.layout_index),
443
+ );
444
+ agent.insert("pane_index".to_string(), serde_json::json!(placement.pane_index));
445
+ agent.insert(
446
+ "display".to_string(),
447
+ serde_json::json!({
448
+ "backend": "adaptive",
449
+ "status": "opened",
450
+ "window": placement.layout_window.as_str(),
451
+ "workspace_window": null,
452
+ "pane_id": spawn.spawn.pane_id.as_str(),
453
+ "pane_title": agent_id.as_str(),
454
+ "target": spawn.spawn.pane_id.as_str(),
455
+ "target_worker_session": spawn.spawn.session.as_str(),
456
+ "linked_session": null,
457
+ "leader_session": spawn.spawn.session.as_str(),
458
+ "display_session": null,
459
+ "fallback": null,
460
+ }),
461
+ );
462
+ }
338
463
  Ok(())
339
464
  }
340
465
 
@@ -458,7 +583,7 @@ fn write_start_agent_start_event(
458
583
  session_name: &SessionName,
459
584
  window: &str,
460
585
  session_id: Option<&SessionId>,
461
- into_existing_session: bool,
586
+ tmux_start_mode: &'static str,
462
587
  ) -> Result<(), LifecycleError> {
463
588
  let auth_mode = agent_auth_mode(agent);
464
589
  let model = agent.get("model").and_then(|v| v.as_str());
@@ -535,11 +660,6 @@ fn write_start_agent_start_event(
535
660
  agent_id.as_str(),
536
661
  team_id,
537
662
  );
538
- let tmux_start_mode = if into_existing_session {
539
- "new-window"
540
- } else {
541
- "new-session"
542
- };
543
663
  crate::event_log::EventLog::new(workspace)
544
664
  .write(
545
665
  "start_agent.agent_start",
@@ -4,6 +4,7 @@ pub(super) struct SpawnedAgentWindow {
4
4
  pub spawn: crate::transport::SpawnResult,
5
5
  pub plan: crate::provider::CommandPlan,
6
6
  pub profile_launch: crate::provider::ProviderProfileLaunch,
7
+ pub layout_placement: Option<crate::lifecycle::launch::LayoutPlacement>,
7
8
  }
8
9
 
9
10
  pub(super) fn spawn_agent_window(
@@ -15,6 +16,7 @@ pub(super) fn spawn_agent_window(
15
16
  into_existing_session: bool,
16
17
  transport: &dyn crate::transport::Transport,
17
18
  safety: Option<&DangerousApproval>,
19
+ layout_placement: Option<&crate::lifecycle::launch::LayoutPlacement>,
18
20
  spawn_cwd_override: Option<&Path>,
19
21
  ) -> Result<SpawnedAgentWindow, LifecycleError> {
20
22
  let provider = agent_provider(agent);
@@ -36,12 +38,11 @@ pub(super) fn spawn_agent_window(
36
38
  detected_safety = crate::lifecycle::launch::effective_runtime_config_for_worker_spawn()?;
37
39
  &detected_safety
38
40
  };
39
- let command_agent =
40
- crate::lifecycle::worker_command_context::WorkerCommandAgent::from_json(
41
- agent,
42
- Some(agent_id.as_str()),
43
- provider,
44
- );
41
+ let command_agent = crate::lifecycle::worker_command_context::WorkerCommandAgent::from_json(
42
+ agent,
43
+ Some(agent_id.as_str()),
44
+ provider,
45
+ );
45
46
  let system_prompt =
46
47
  crate::lifecycle::worker_command_context::compile_worker_system_prompt(&command_agent)?;
47
48
  let tools = crate::lifecycle::worker_command_context::resolved_tool_strings_for_command(
@@ -60,7 +61,8 @@ pub(super) fn spawn_agent_window(
60
61
  .and_then(|v| v.as_str())
61
62
  .map(str::to_string)
62
63
  .or_else(|| {
63
- let key = crate::messaging::leader_receiver::active_team_key(workspace, &state_for_team);
64
+ let key =
65
+ crate::messaging::leader_receiver::active_team_key(workspace, &state_for_team);
64
66
  (!key.is_empty()).then_some(key)
65
67
  });
66
68
  let mcp_config = adapter
@@ -72,8 +74,11 @@ pub(super) fn spawn_agent_window(
72
74
  agent_id.as_str(),
73
75
  team_id.as_deref().unwrap_or(""),
74
76
  );
75
- let mcp_config_path =
76
- crate::lifecycle::launch::write_worker_mcp_config(workspace, agent_id.as_str(), &mcp_config)?;
77
+ let mcp_config_path = crate::lifecycle::launch::write_worker_mcp_config(
78
+ workspace,
79
+ agent_id.as_str(),
80
+ &mcp_config,
81
+ )?;
77
82
  let profile_launch =
78
83
  crate::lifecycle::profile_launch::prepare_provider_profile_launch_from_json(
79
84
  workspace,
@@ -81,11 +86,7 @@ pub(super) fn spawn_agent_window(
81
86
  agent,
82
87
  Some(&mcp_config),
83
88
  )?;
84
- let command_model = profile_launch
85
- .command_overrides
86
- .model
87
- .as_deref()
88
- .or(model);
89
+ let command_model = profile_launch.command_overrides.model.as_deref().or(model);
89
90
  let context = crate::provider::ProviderCommandContext {
90
91
  auth_mode,
91
92
  mcp_config: Some(&mcp_config),
@@ -115,7 +116,9 @@ pub(super) fn spawn_agent_window(
115
116
  agent_id.as_str(),
116
117
  team_id.as_deref(),
117
118
  );
118
- let window = WindowName::new(agent_id.as_str());
119
+ let window = layout_placement
120
+ .map(|placement| placement.layout_window.clone())
121
+ .unwrap_or_else(|| WindowName::new(agent_id.as_str()));
119
122
  let mut env = crate::lifecycle::launch::inherited_env_with_team_overrides(
120
123
  workspace,
121
124
  agent_id.as_str(),
@@ -133,7 +136,38 @@ pub(super) fn spawn_agent_window(
133
136
  })
134
137
  .unwrap_or(workspace);
135
138
  let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
136
- let result = if into_existing_session {
139
+ let result = if let Some(placement) = layout_placement {
140
+ if placement.starts_window {
141
+ if into_existing_session {
142
+ transport.spawn_into_with_env_unset(
143
+ session_name,
144
+ &window,
145
+ &plan.argv,
146
+ spawn_cwd,
147
+ &env,
148
+ &env_unset,
149
+ )
150
+ } else {
151
+ transport.spawn_first_with_env_unset(
152
+ session_name,
153
+ &window,
154
+ &plan.argv,
155
+ spawn_cwd,
156
+ &env,
157
+ &env_unset,
158
+ )
159
+ }
160
+ } else {
161
+ transport.spawn_split_with_env_unset(
162
+ session_name,
163
+ &window,
164
+ &plan.argv,
165
+ spawn_cwd,
166
+ &env,
167
+ &env_unset,
168
+ )
169
+ }
170
+ } else if into_existing_session {
137
171
  transport.spawn_into_with_env_unset(
138
172
  session_name,
139
173
  &window,
@@ -163,6 +197,7 @@ pub(super) fn spawn_agent_window(
163
197
  spawn,
164
198
  plan,
165
199
  profile_launch,
200
+ layout_placement: layout_placement.cloned(),
166
201
  })
167
202
  }
168
203
 
@@ -252,19 +287,30 @@ pub(super) fn resume_backing_exists_for_agent(
252
287
  rollout_path: Option<&RolloutPath>,
253
288
  ) -> bool {
254
289
  match provider {
290
+ provider if !provider_supports_resume(provider) => {
291
+ let _ = (workspace, agent_id, agent, session_id, rollout_path);
292
+ false
293
+ }
255
294
  Provider::Codex => rollout_path_exists(rollout_path),
256
295
  Provider::Claude | Provider::ClaudeCode => {
257
296
  rollout_path_exists(rollout_path)
258
297
  || event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
259
298
  }
260
299
  Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
261
- Provider::GeminiCli | Provider::Fake => {
262
- let _ = agent;
263
- true
264
- }
300
+ Provider::GeminiCli | Provider::Fake => false,
265
301
  }
266
302
  }
267
303
 
304
+ pub(super) fn provider_supports_resume(provider: Provider) -> bool {
305
+ crate::provider::get_adapter(provider).caps().resume
306
+ }
307
+
308
+ pub(super) fn provider_wire_supports_resume(provider: &str) -> bool {
309
+ parse_provider(provider)
310
+ .map(provider_supports_resume)
311
+ .unwrap_or(false)
312
+ }
313
+
268
314
  fn rollout_path_exists(rollout_path: Option<&RolloutPath>) -> bool {
269
315
  rollout_path
270
316
  .as_ref()
@@ -308,13 +354,12 @@ fn copilot_session_store_has_session(session_id: &str) -> bool {
308
354
  ) else {
309
355
  return false;
310
356
  };
311
- conn
312
- .query_row(
313
- "select 1 from sessions where id = ?1 limit 1",
314
- [session_id],
315
- |_| Ok(()),
316
- )
317
- .is_ok()
357
+ conn.query_row(
358
+ "select 1 from sessions where id = ?1 limit 1",
359
+ [session_id],
360
+ |_| Ok(()),
361
+ )
362
+ .is_ok()
318
363
  }
319
364
 
320
365
  pub(crate) fn refresh_missing_provider_sessions(
@@ -469,7 +514,10 @@ pub(super) fn load_team_spec(workspace: &Path) -> Result<YamlValue, LifecycleErr
469
514
  yaml::loads(&text).map_err(|e| LifecycleError::Compile(e.to_string()))
470
515
  }
471
516
 
472
- pub(super) fn find_spec_agent<'a>(spec: &'a YamlValue, agent_id: &AgentId) -> Option<&'a YamlValue> {
517
+ pub(super) fn find_spec_agent<'a>(
518
+ spec: &'a YamlValue,
519
+ agent_id: &AgentId,
520
+ ) -> Option<&'a YamlValue> {
473
521
  let leader_is_agent = spec
474
522
  .get("leader")
475
523
  .and_then(|v| v.get("id"))
@@ -479,23 +527,23 @@ pub(super) fn find_spec_agent<'a>(spec: &'a YamlValue, agent_id: &AgentId) -> Op
479
527
  if leader_is_agent {
480
528
  return None;
481
529
  }
482
- spec.get("agents")?
483
- .as_list()?
484
- .iter()
485
- .find(|agent| {
486
- agent
487
- .get("id")
488
- .and_then(YamlValue::as_str)
489
- .map(|id| id == agent_id.as_str())
490
- .unwrap_or(false)
491
- })
530
+ spec.get("agents")?.as_list()?.iter().find(|agent| {
531
+ agent
532
+ .get("id")
533
+ .and_then(YamlValue::as_str)
534
+ .map(|id| id == agent_id.as_str())
535
+ .unwrap_or(false)
536
+ })
492
537
  }
493
538
 
494
539
  pub(super) fn unknown_worker(agent_id: &AgentId) -> LifecycleError {
495
540
  LifecycleError::RequirementUnmet(format!("unknown worker agent id: {agent_id}"))
496
541
  }
497
542
 
498
- pub(super) fn state_session_name_from_spec(state: &serde_json::Value, spec: &YamlValue) -> SessionName {
543
+ pub(super) fn state_session_name_from_spec(
544
+ state: &serde_json::Value,
545
+ spec: &YamlValue,
546
+ ) -> SessionName {
499
547
  state
500
548
  .get("session_name")
501
549
  .and_then(|v| v.as_str())
@@ -709,9 +757,7 @@ pub(super) fn agent_is_running(
709
757
  agent_id: &AgentId,
710
758
  transport: &dyn crate::transport::Transport,
711
759
  ) -> bool {
712
- let agent_state = state
713
- .get("agents")
714
- .and_then(|v| v.get(agent_id.as_str()));
760
+ let agent_state = state.get("agents").and_then(|v| v.get(agent_id.as_str()));
715
761
  let status = agent_state
716
762
  .and_then(|v| v.get("status"))
717
763
  .and_then(|v| v.as_str())