@team-agent/installer 0.3.0 → 0.3.2

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 (39) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +38 -7
  4. package/crates/team-agent/src/cli/emit.rs +182 -54
  5. package/crates/team-agent/src/cli/mod.rs +703 -35
  6. package/crates/team-agent/src/cli/status_port.rs +170 -44
  7. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  8. package/crates/team-agent/src/cli/types.rs +1 -0
  9. package/crates/team-agent/src/coordinator/health.rs +130 -0
  10. package/crates/team-agent/src/leader/lease.rs +23 -2
  11. package/crates/team-agent/src/leader/rediscover/tests.rs +1 -0
  12. package/crates/team-agent/src/leader/rediscover.rs +2 -0
  13. package/crates/team-agent/src/leader/tests/byte_findings.rs +9 -6
  14. package/crates/team-agent/src/leader/tests/idle.rs +1 -0
  15. package/crates/team-agent/src/leader/tests/lease_claim.rs +157 -0
  16. package/crates/team-agent/src/leader/types.rs +2 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +554 -65
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +65 -0
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +57 -15
  20. package/crates/team-agent/src/lifecycle/restart/remove.rs +5 -1
  21. package/crates/team-agent/src/lifecycle/restart.rs +20 -0
  22. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +52 -0
  23. package/crates/team-agent/src/lifecycle/types.rs +25 -0
  24. package/crates/team-agent/src/mcp_server/tests/wire.rs +28 -0
  25. package/crates/team-agent/src/mcp_server/wire.rs +81 -1
  26. package/crates/team-agent/src/messaging/delivery.rs +574 -12
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +26 -37
  28. package/crates/team-agent/src/messaging/mod.rs +1 -1
  29. package/crates/team-agent/src/messaging/results.rs +218 -49
  30. package/crates/team-agent/src/messaging/send.rs +15 -19
  31. package/crates/team-agent/src/provider/adapter.rs +95 -10
  32. package/crates/team-agent/src/provider/helpers.rs +10 -1
  33. package/crates/team-agent/src/state/identity.rs +3 -0
  34. package/crates/team-agent/src/state/persist.rs +113 -1
  35. package/crates/team-agent/src/state/projection.rs +127 -3
  36. package/crates/team-agent/src/tmux_backend/tests.rs +179 -0
  37. package/crates/team-agent/src/tmux_backend.rs +124 -12
  38. package/npm/install.mjs +29 -7
  39. package/package.json +4 -4
@@ -1,12 +1,12 @@
1
1
  //! lifecycle::launch —— 冷启 / quick-start / 危险审批探测 + add/fork / plan 起步与推进。
2
2
 
3
- use std::collections::BTreeMap;
3
+ use std::collections::{BTreeMap, BTreeSet};
4
4
  use std::path::{Path, PathBuf};
5
5
  use std::process::Command;
6
6
 
7
- use crate::model::permissions::{self, AgentPermissionInput};
7
+ use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider};
8
8
  use crate::model::ids::AgentId;
9
- use crate::model::enums::{AuthMode, DisplayBackend, Provider};
9
+ use crate::model::permissions::{self, AgentPermissionInput};
10
10
  use crate::model::yaml::{self, Value};
11
11
  use crate::state::persist::{load_runtime_state, save_runtime_state};
12
12
  use crate::transport::{SessionName, Target, Transport, WindowName};
@@ -44,6 +44,26 @@ pub fn launch_with_transport(
44
44
  auto_approve: bool,
45
45
  skip_profile_smoke: bool,
46
46
  transport: &dyn Transport,
47
+ ) -> Result<LaunchReport, LifecycleError> {
48
+ let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
49
+ let workspace = team_workspace(team_dir);
50
+ launch_with_transport_in_workspace(
51
+ &workspace,
52
+ spec_path,
53
+ dry_run,
54
+ auto_approve,
55
+ skip_profile_smoke,
56
+ transport,
57
+ )
58
+ }
59
+
60
+ pub fn launch_with_transport_in_workspace(
61
+ workspace: &Path,
62
+ spec_path: &Path,
63
+ dry_run: bool,
64
+ auto_approve: bool,
65
+ skip_profile_smoke: bool,
66
+ transport: &dyn Transport,
47
67
  ) -> Result<LaunchReport, LifecycleError> {
48
68
  let _ = skip_profile_smoke;
49
69
  if !spec_path.exists() {
@@ -76,13 +96,13 @@ pub fn launch_with_transport(
76
96
  raw: serde_json::json!({"source": "compiled_spec"}),
77
97
  })
78
98
  .collect::<Vec<_>>();
79
- write_launch_permission_audit(&team_workspace(spec_path.parent().unwrap_or_else(|| Path::new("."))), &safety)?;
99
+ write_launch_permission_audit(workspace, &safety)?;
80
100
  let routes = spec_routes(&spec);
81
101
  let started = if dry_run {
82
102
  Vec::new()
83
103
  } else {
84
- let started = spawn_agents(spec_path, &spec, &session_name, &safety, transport)?;
85
- persist_spawn_agent_state(spec_path, &spec)?;
104
+ let started = spawn_agents(workspace, spec_path, &spec, &session_name, &safety, transport)?;
105
+ persist_spawn_agent_state(workspace, spec_path, &spec, &session_name, transport, &started)?;
86
106
  started
87
107
  };
88
108
  Ok(LaunchReport {
@@ -97,6 +117,7 @@ pub fn launch_with_transport(
97
117
  }
98
118
 
99
119
  fn spawn_agents(
120
+ workspace: &Path,
100
121
  spec_path: &Path,
101
122
  spec: &Value,
102
123
  session_name: &SessionName,
@@ -104,7 +125,6 @@ fn spawn_agents(
104
125
  transport: &dyn Transport,
105
126
  ) -> Result<Vec<StartedAgent>, LifecycleError> {
106
127
  let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
107
- let workspace = team_workspace(team_dir);
108
128
  let mut started = Vec::new();
109
129
  for agent in spec_agent_values(spec) {
110
130
  let Some(agent_id_raw) = agent.get("id").and_then(Value::as_str) else {
@@ -134,10 +154,13 @@ fn spawn_agents(
134
154
  let role = agent.get("role").and_then(Value::as_str);
135
155
  let tools = worker_tool_refs(agent_tool_strings(agent), safety);
136
156
  let tool_refs: Vec<&str> = tools.iter().map(String::as_str).collect();
157
+ let mcp_team_id =
158
+ runtime_active_team_key_for_spawn(workspace, spec_path, spec, session_name);
137
159
  let mcp_config = adapter
138
160
  .mcp_config(auth_mode)
139
161
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
140
- let team_id = spec_team_id(spec);
162
+ let mcp_config = resolve_mcp_config(mcp_config, workspace, agent_id_raw, &mcp_team_id);
163
+ let mcp_config_path = write_worker_mcp_config(workspace, agent_id_raw, &mcp_config)?;
141
164
  let mut argv = adapter
142
165
  .build_command_with_tools(
143
166
  auth_mode,
@@ -147,12 +170,18 @@ fn spawn_agents(
147
170
  &tool_refs,
148
171
  )
149
172
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
150
- fill_spawn_placeholders_full(&mut argv, &workspace, agent_id_raw, team_id.as_deref());
173
+ point_native_mcp_config_at_file(&mut argv, provider, &mcp_config_path);
174
+ fill_spawn_placeholders_full(
175
+ &mut argv,
176
+ workspace,
177
+ agent_id_raw,
178
+ Some(&mcp_team_id),
179
+ );
151
180
  let window = WindowName::new(agent_id_raw);
152
181
  let env = inherited_env_with_team_overrides(
153
- &workspace,
182
+ workspace,
154
183
  agent_id_raw,
155
- team_id.as_deref(),
184
+ Some(&mcp_team_id),
156
185
  );
157
186
  let spawn = if started.is_empty() {
158
187
  transport.spawn_first(session_name, &window, &argv, team_dir, &env)
@@ -166,6 +195,9 @@ fn spawn_agents(
166
195
  30,
167
196
  0.5,
168
197
  );
198
+ if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
199
+ continue;
200
+ }
169
201
  started.push(StartedAgent {
170
202
  agent_id,
171
203
  start_mode: StartMode::Fresh,
@@ -180,10 +212,15 @@ fn spawn_agents(
180
212
  Ok(started)
181
213
  }
182
214
 
183
- fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), LifecycleError> {
184
- let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
185
- let workspace = team_workspace(team_dir);
186
- let state_path = crate::state::persist::runtime_state_path(&workspace);
215
+ fn persist_spawn_agent_state(
216
+ workspace: &Path,
217
+ spec_path: &Path,
218
+ spec: &Value,
219
+ session_name: &SessionName,
220
+ transport: &dyn Transport,
221
+ started: &[StartedAgent],
222
+ ) -> Result<(), LifecycleError> {
223
+ let state_path = crate::state::persist::runtime_state_path(workspace);
187
224
  let mut state = if state_path.exists() {
188
225
  let text = std::fs::read_to_string(&state_path)
189
226
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", state_path.display())))?;
@@ -192,6 +229,27 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
192
229
  } else {
193
230
  serde_json::json!({"agents": {}})
194
231
  };
232
+ let team_id = explicit_active_team_key(&state)
233
+ .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
234
+ let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
235
+ drop_worker_pane_seeded_owner(
236
+ &mut state,
237
+ &team_id,
238
+ started,
239
+ worker_tmux_socket.as_deref(),
240
+ );
241
+ // Only persist running state for agents whose spawn still has a live target.
242
+ let live_windows: BTreeSet<String> = transport
243
+ .list_windows(session_name)
244
+ .unwrap_or_default()
245
+ .into_iter()
246
+ .map(|w| w.as_str().to_string())
247
+ .collect();
248
+ let live_started_agents: BTreeSet<String> = started
249
+ .iter()
250
+ .map(|agent| agent.agent_id.as_str().to_string())
251
+ .collect();
252
+ let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
195
253
  let mut agents = serde_json::Map::new();
196
254
  let spawned_at = spawn_timestamp();
197
255
  for agent in spec_agent_values(spec) {
@@ -210,9 +268,26 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
210
268
  agents.insert(id.to_string(), serde_json::Value::Object(paused));
211
269
  continue;
212
270
  }
271
+ let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
272
+ if !live_started_agents.contains(id)
273
+ || (!live_windows.is_empty() && !live_windows.contains(window))
274
+ {
275
+ let mut failed = serde_json::Map::new();
276
+ failed.insert("status".to_string(), serde_json::json!("spawn_failed"));
277
+ failed.insert("provider".to_string(), serde_json::json!(provider));
278
+ failed.insert("agent_id".to_string(), serde_json::json!(id));
279
+ failed.insert("window".to_string(), serde_json::json!(window));
280
+ failed.insert(
281
+ "reason".to_string(),
282
+ serde_json::json!("tmux window not present after spawn"),
283
+ );
284
+ agents.insert(id.to_string(), serde_json::Value::Object(failed));
285
+ continue;
286
+ }
287
+ let pane_pid = pane_pids_by_agent.get(id).copied();
213
288
  agents.insert(
214
289
  id.to_string(),
215
- running_agent_state(agent, id, provider, &workspace, &spawned_at)?,
290
+ running_agent_state(agent, id, provider, workspace, &spawned_at, &team_id, pane_pid)?,
216
291
  );
217
292
  }
218
293
  if let Some(obj) = state.as_object_mut() {
@@ -222,21 +297,108 @@ fn persist_spawn_agent_state(spec_path: &Path, spec: &Value) -> Result<(), Lifec
222
297
  obj.insert("agents".to_string(), serde_json::Value::Object(agents));
223
298
  state = serde_json::Value::Object(obj);
224
299
  }
225
- save_launched_team_state(&workspace, &state)
300
+ save_launched_team_state_for_key(workspace, &state, Some(&team_id))
301
+ }
302
+
303
+ fn pane_pids_by_started_agent(
304
+ transport: &dyn Transport,
305
+ started: &[StartedAgent],
306
+ ) -> BTreeMap<String, u32> {
307
+ let panes = transport.list_targets().unwrap_or_default();
308
+ started
309
+ .iter()
310
+ .filter_map(|agent| {
311
+ panes
312
+ .iter()
313
+ .find(|pane| pane.pane_id.as_str() == agent.target)
314
+ .and_then(|pane| pane.pane_pid)
315
+ .map(|pid| (agent.agent_id.as_str().to_string(), pid))
316
+ })
317
+ .collect()
226
318
  }
227
319
 
228
320
  fn save_launched_team_state(workspace: &Path, launched: &serde_json::Value) -> Result<(), LifecycleError> {
321
+ save_launched_team_state_for_key(workspace, launched, None)
322
+ }
323
+
324
+ fn save_launched_team_state_for_key(
325
+ workspace: &Path,
326
+ launched: &serde_json::Value,
327
+ team_key: Option<&str>,
328
+ ) -> Result<(), LifecycleError> {
229
329
  let existing = load_runtime_state(workspace).unwrap_or_else(|_| serde_json::json!({}));
230
- let launched_key = crate::state::projection::team_state_key(launched);
330
+ let launched_key = team_key
331
+ .filter(|key| !key.is_empty())
332
+ .map(str::to_string)
333
+ .unwrap_or_else(|| crate::state::projection::team_state_key(launched));
231
334
  let mut launched = launched.clone();
335
+ if let Some(obj) = launched.as_object_mut() {
336
+ obj.insert(
337
+ "active_team_key".to_string(),
338
+ serde_json::Value::String(launched_key.clone()),
339
+ );
340
+ }
232
341
  promote_launched_binding_from_team_entry(&mut launched, &launched_key);
233
342
  drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
234
- let merged = crate::state::projection::merge_workspace_team_state(&existing, &launched);
343
+ let merged = if team_key.is_some() {
344
+ merge_workspace_team_state_with_key(&existing, &launched, &launched_key)
345
+ } else {
346
+ crate::state::projection::merge_workspace_team_state(&existing, &launched)
347
+ };
235
348
  let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
236
349
  drop_unbound_top_level_owner(&mut projected);
237
350
  save_runtime_state(workspace, &projected).map_err(|e| LifecycleError::StatePersist(e.to_string()))
238
351
  }
239
352
 
353
+ fn merge_workspace_team_state_with_key(
354
+ existing: &serde_json::Value,
355
+ launched: &serde_json::Value,
356
+ launched_key: &str,
357
+ ) -> serde_json::Value {
358
+ let mut launched_obj = launched.as_object().cloned().unwrap_or_default();
359
+ let mut teams = launched
360
+ .get("teams")
361
+ .and_then(serde_json::Value::as_object)
362
+ .cloned()
363
+ .unwrap_or_default();
364
+ let launched_entry = crate::state::projection::compact_team_state(launched);
365
+ if !existing
366
+ .get("session_name")
367
+ .and_then(serde_json::Value::as_str)
368
+ .is_some_and(|session| !session.is_empty())
369
+ {
370
+ teams.insert(launched_key.to_string(), launched_entry);
371
+ launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
372
+ return serde_json::Value::Object(launched_obj);
373
+ }
374
+
375
+ let existing_key = explicit_active_team_key(existing)
376
+ .unwrap_or_else(|| crate::state::projection::team_state_key(existing));
377
+ if existing_key == launched_key {
378
+ let mut teams = existing
379
+ .get("teams")
380
+ .and_then(serde_json::Value::as_object)
381
+ .cloned()
382
+ .unwrap_or_default();
383
+ teams.insert(launched_key.to_string(), launched_entry);
384
+ launched_obj.insert("teams".to_string(), serde_json::Value::Object(teams));
385
+ return serde_json::Value::Object(launched_obj);
386
+ }
387
+
388
+ let mut merged = existing.as_object().cloned().unwrap_or_default();
389
+ let mut teams = merged
390
+ .get("teams")
391
+ .and_then(serde_json::Value::as_object)
392
+ .cloned()
393
+ .unwrap_or_default();
394
+ teams
395
+ .entry(existing_key)
396
+ .or_insert_with(|| crate::state::projection::compact_team_state(existing));
397
+ teams.insert(launched_key.to_string(), launched_entry);
398
+ merged.insert("teams".to_string(), serde_json::Value::Object(teams));
399
+ serde_json::Value::Object(merged)
400
+ }
401
+
240
402
  fn promote_launched_binding_from_team_entry(launched: &mut serde_json::Value, launched_key: &str) {
241
403
  let entry = launched
242
404
  .get("teams")
@@ -287,6 +449,69 @@ fn drop_foreign_seeded_owner(existing: &serde_json::Value, launched_key: &str, l
287
449
  }
288
450
  }
289
451
 
452
+ fn drop_worker_pane_seeded_owner(
453
+ launched: &mut serde_json::Value,
454
+ launched_key: &str,
455
+ started: &[StartedAgent],
456
+ worker_tmux_socket: Option<&str>,
457
+ ) {
458
+ let Some(pane) = launched
459
+ .get("team_owner")
460
+ .and_then(|owner| owner.get("pane_id"))
461
+ .and_then(serde_json::Value::as_str)
462
+ .filter(|pane| !pane.is_empty())
463
+ else {
464
+ return;
465
+ };
466
+ let leader_pane = std::env::var("TEAM_AGENT_LEADER_PANE_ID")
467
+ .ok()
468
+ .filter(|value| !value.is_empty());
469
+ let tmux_pane = std::env::var("TMUX_PANE")
470
+ .ok()
471
+ .filter(|value| !value.is_empty());
472
+ let has_leader_identity_env = leader_pane.is_some()
473
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID")
474
+ || env_nonempty("TEAM_AGENT_LEADER_SESSION_UUID_OVERRIDE")
475
+ || env_nonempty("TEAM_AGENT_LEADER_PROVIDER")
476
+ || env_nonempty("TEAM_AGENT_ID")
477
+ || env_nonempty("TEAM_AGENT_TEAM_ID");
478
+ let seeded_from_bare_tmux =
479
+ !has_leader_identity_env && tmux_pane.as_deref() == Some(pane);
480
+ let caller_tmux_socket = crate::tmux_backend::socket_name_from_tmux_env();
481
+ if seeded_from_bare_tmux
482
+ && tmux_sockets_match_or_unknown(caller_tmux_socket.as_deref(), worker_tmux_socket)
483
+ && started.iter().any(|agent| agent.target == pane)
484
+ {
485
+ seed_unbound_launched_owner(launched, launched_key);
486
+ }
487
+ }
488
+
489
+ fn launched_worker_tmux_socket(
490
+ transport: &dyn Transport,
491
+ workspace: &Path,
492
+ ) -> Option<String> {
493
+ if matches!(transport.kind(), crate::transport::BackendKind::Tmux) {
494
+ Some(crate::tmux_backend::socket_name_for_workspace(workspace))
495
+ } else {
496
+ None
497
+ }
498
+ }
499
+
500
+ fn tmux_sockets_match_or_unknown(
501
+ caller_socket: Option<&str>,
502
+ worker_socket: Option<&str>,
503
+ ) -> bool {
504
+ match (caller_socket, worker_socket) {
505
+ (Some(caller), Some(worker)) => caller == worker,
506
+ (Some(_), None) => false,
507
+ (None, _) => true,
508
+ }
509
+ }
510
+
511
+ fn env_nonempty(key: &str) -> bool {
512
+ std::env::var(key).ok().is_some_and(|value| !value.is_empty())
513
+ }
514
+
290
515
  fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &str) {
291
516
  let provider = launched
292
517
  .get("team_owner")
@@ -330,6 +555,7 @@ fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &
330
555
  "status": "attached",
331
556
  "provider": provider,
332
557
  "pane_id": "__team_agent_unbound__",
558
+ "pane": "__team_agent_unbound__",
333
559
  "leader_session_uuid": uuid.as_str(),
334
560
  "owner_epoch": owner_epoch,
335
561
  "discovery": "quick_start",
@@ -363,6 +589,8 @@ fn running_agent_state(
363
589
  provider: Provider,
364
590
  workspace: &Path,
365
591
  spawned_at: &str,
592
+ team_id: &str,
593
+ pane_pid: Option<u32>,
366
594
  ) -> Result<serde_json::Value, LifecycleError> {
367
595
  let model = agent.get("model").and_then(Value::as_str);
368
596
  let auth_mode = agent
@@ -372,6 +600,11 @@ fn running_agent_state(
372
600
  .unwrap_or(AuthMode::Subscription);
373
601
  let profile = agent.get("profile").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null);
374
602
  let window = agent.get("window").and_then(Value::as_str).unwrap_or(id);
603
+ let mcp_config = crate::provider::get_adapter(provider)
604
+ .mcp_config(auth_mode)
605
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?;
606
+ let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
607
+ let mcp_config_path = write_worker_mcp_config(workspace, id, &mcp_config)?;
375
608
  let mut state = serde_json::Map::new();
376
609
  state.insert("status".to_string(), serde_json::json!("running"));
377
610
  state.insert("provider".to_string(), serde_json::json!(provider));
@@ -382,13 +615,7 @@ fn running_agent_state(
382
615
  state.insert("window".to_string(), serde_json::json!(window));
383
616
  state.insert(
384
617
  "mcp_config".to_string(),
385
- serde_json::json!(
386
- workspace
387
- .join(".team/runtime/mcp")
388
- .join(format!("{id}.json"))
389
- .to_string_lossy()
390
- .to_string()
391
- ),
618
+ serde_json::json!(mcp_config_path.to_string_lossy().to_string()),
392
619
  );
393
620
  state.insert(
394
621
  "permissions".to_string(),
@@ -405,9 +632,86 @@ fn running_agent_state(
405
632
  serde_json::json!(workspace.to_string_lossy().to_string()),
406
633
  );
407
634
  state.insert("spawned_at".to_string(), serde_json::json!(spawned_at));
635
+ if let Some(pane_pid) = pane_pid {
636
+ state.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
637
+ }
408
638
  Ok(serde_json::Value::Object(state))
409
639
  }
410
640
 
641
+ fn resolve_mcp_config(
642
+ config: crate::provider::McpConfig,
643
+ workspace: &Path,
644
+ agent_id: &str,
645
+ team_id: &str,
646
+ ) -> crate::provider::McpConfig {
647
+ crate::provider::McpConfig {
648
+ raw: resolve_mcp_placeholders(config.raw, workspace, agent_id, team_id),
649
+ }
650
+ }
651
+
652
+ fn resolve_mcp_placeholders(
653
+ value: serde_json::Value,
654
+ workspace: &Path,
655
+ agent_id: &str,
656
+ team_id: &str,
657
+ ) -> serde_json::Value {
658
+ match value {
659
+ serde_json::Value::String(s) => serde_json::Value::String(
660
+ s.replace("{workspace}", &workspace.to_string_lossy())
661
+ .replace("{agent_id}", agent_id)
662
+ .replace("{team_id}", team_id),
663
+ ),
664
+ serde_json::Value::Array(items) => serde_json::Value::Array(
665
+ items
666
+ .into_iter()
667
+ .map(|item| resolve_mcp_placeholders(item, workspace, agent_id, team_id))
668
+ .collect(),
669
+ ),
670
+ serde_json::Value::Object(map) => serde_json::Value::Object(
671
+ map.into_iter()
672
+ .map(|(key, value)| {
673
+ (
674
+ key,
675
+ resolve_mcp_placeholders(value, workspace, agent_id, team_id),
676
+ )
677
+ })
678
+ .collect(),
679
+ ),
680
+ other => other,
681
+ }
682
+ }
683
+
684
+ fn write_worker_mcp_config(
685
+ workspace: &Path,
686
+ agent_id: &str,
687
+ config: &crate::provider::McpConfig,
688
+ ) -> Result<PathBuf, LifecycleError> {
689
+ let path = workspace
690
+ .join(".team/runtime/mcp")
691
+ .join(format!("{agent_id}.json"));
692
+ if let Some(parent) = path.parent() {
693
+ std::fs::create_dir_all(parent)
694
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
695
+ }
696
+ let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": config.raw}))
697
+ .map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
698
+ std::fs::write(&path, body)
699
+ .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
700
+ Ok(path)
701
+ }
702
+
703
+ fn point_native_mcp_config_at_file(argv: &mut [String], provider: Provider, path: &Path) {
704
+ if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
705
+ return;
706
+ }
707
+ let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
708
+ return;
709
+ };
710
+ if let Some(value) = argv.get_mut(index.saturating_add(1)) {
711
+ *value = path.to_string_lossy().to_string();
712
+ }
713
+ }
714
+
411
715
  fn permissions_json(
412
716
  agent: &Value,
413
717
  id: &str,
@@ -565,6 +869,37 @@ fn spec_team_id(spec: &Value) -> Option<String> {
565
869
  })
566
870
  }
567
871
 
872
+ fn runtime_active_team_key_for_spawn(
873
+ workspace: &Path,
874
+ spec_path: &Path,
875
+ spec: &Value,
876
+ session_name: &SessionName,
877
+ ) -> String {
878
+ load_runtime_state(workspace)
879
+ .ok()
880
+ .and_then(|state| explicit_active_team_key(&state))
881
+ .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
882
+ }
883
+
884
+ fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
885
+ state
886
+ .get("active_team_key")
887
+ .and_then(serde_json::Value::as_str)
888
+ .filter(|team| !team.is_empty() && *team != "current")
889
+ .map(str::to_string)
890
+ }
891
+
892
+ fn runtime_team_key_for_spec(spec_path: &Path, spec: &Value, session_name: &SessionName) -> String {
893
+ let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
894
+ let state = serde_json::json!({
895
+ "team_dir": team_dir.to_string_lossy(),
896
+ "spec_path": spec_path.to_string_lossy(),
897
+ "session_name": session_name.as_str(),
898
+ "team": spec.get("team").map(yaml_value_to_json).unwrap_or(serde_json::Value::Null),
899
+ });
900
+ crate::state::projection::team_state_key(&state)
901
+ }
902
+
568
903
  fn transport_has_session(transport: &dyn Transport, session_name: &SessionName) -> bool {
569
904
  match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
570
905
  transport.has_session(session_name)
@@ -594,6 +929,43 @@ fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
594
929
  }
595
930
  }
596
931
 
932
+ fn quick_start_requested_team_key<'a>(team_id: Option<&'a str>, name: Option<&'a str>) -> Option<&'a str> {
933
+ team_id.or(name).filter(|team| !team.is_empty())
934
+ }
935
+
936
+ fn runtime_state_has_quick_start_team(state: &serde_json::Value, team: &str) -> bool {
937
+ explicit_active_team_key(state).as_deref() == Some(team)
938
+ || state
939
+ .get("teams")
940
+ .and_then(serde_json::Value::as_object)
941
+ .is_some_and(|teams| {
942
+ teams.contains_key(team)
943
+ || teams
944
+ .values()
945
+ .any(|entry| json_team_identity_matches(entry, team))
946
+ })
947
+ || crate::state::projection::team_state_key(state) == team
948
+ || json_team_identity_matches(state, team)
949
+ || state
950
+ .get("session_name")
951
+ .and_then(serde_json::Value::as_str)
952
+ .is_some_and(|session| {
953
+ session == team || session.strip_prefix("team-") == Some(team)
954
+ })
955
+ }
956
+
957
+ fn json_team_identity_matches(state: &serde_json::Value, team: &str) -> bool {
958
+ state
959
+ .get("team")
960
+ .and_then(|value| value.get("id").or_else(|| value.get("name")))
961
+ .and_then(serde_json::Value::as_str)
962
+ .is_some_and(|value| value == team)
963
+ || state
964
+ .get("name")
965
+ .and_then(serde_json::Value::as_str)
966
+ .is_some_and(|value| value == team)
967
+ }
968
+
597
969
  /// `quick_start(agents_dir, name, yes, fresh, team_id)`(`diagnose/quick_start.py:18`)。
598
970
  /// 面向用户的零配置入口:编译 team_dir → `launch` → autobind leader receiver → 起
599
971
  /// coordinator → `wait_ready` 轮询就绪。归入 lifecycle module(不与 diagnose 混)。
@@ -604,14 +976,29 @@ pub fn quick_start(
604
976
  fresh: bool,
605
977
  team_id: Option<&str>,
606
978
  ) -> Result<QuickStartReport, LifecycleError> {
607
- quick_start_with_transport(
979
+ let workspace = team_workspace(agents_dir);
980
+ quick_start_in_workspace(&workspace, agents_dir, name, yes, fresh, team_id)
981
+ }
982
+
983
+ pub fn quick_start_in_workspace(
984
+ workspace: &Path,
985
+ agents_dir: &Path,
986
+ name: Option<&str>,
987
+ yes: bool,
988
+ fresh: bool,
989
+ team_id: Option<&str>,
990
+ ) -> Result<QuickStartReport, LifecycleError> {
991
+ let workspace = crate::model::paths::canonical_run_workspace(workspace)
992
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
993
+ quick_start_with_transport_in_workspace(
994
+ &workspace,
608
995
  agents_dir,
609
996
  name,
610
997
  yes,
611
998
  fresh,
612
999
  team_id,
613
- // CP-1: per-team socket bound to the run workspace (team_workspace(agents_dir)).
614
- &crate::tmux_backend::TmuxBackend::for_workspace(&team_workspace(agents_dir)),
1000
+ // CP-1: per-team socket bound to the selected run workspace.
1001
+ &crate::tmux_backend::TmuxBackend::for_workspace(&workspace),
615
1002
  )
616
1003
  }
617
1004
 
@@ -624,6 +1011,19 @@ pub fn quick_start_with_transport(
624
1011
  fresh: bool,
625
1012
  team_id: Option<&str>,
626
1013
  transport: &dyn Transport,
1014
+ ) -> Result<QuickStartReport, LifecycleError> {
1015
+ let workspace = team_workspace(agents_dir);
1016
+ quick_start_with_transport_in_workspace(&workspace, agents_dir, name, yes, fresh, team_id, transport)
1017
+ }
1018
+
1019
+ pub fn quick_start_with_transport_in_workspace(
1020
+ workspace: &Path,
1021
+ agents_dir: &Path,
1022
+ name: Option<&str>,
1023
+ yes: bool,
1024
+ fresh: bool,
1025
+ team_id: Option<&str>,
1026
+ transport: &dyn Transport,
627
1027
  ) -> Result<QuickStartReport, LifecycleError> {
628
1028
  if !agents_dir.exists() {
629
1029
  return Err(LifecycleError::Compile(format!(
@@ -631,34 +1031,43 @@ pub fn quick_start_with_transport(
631
1031
  agents_dir.display()
632
1032
  )));
633
1033
  }
634
- let workspace = team_workspace(agents_dir);
1034
+ let workspace = workspace.to_path_buf();
1035
+ let mut spec = crate::compiler::compile_team(agents_dir)
1036
+ .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1037
+ let requested_team = quick_start_requested_team_key(team_id, name)
1038
+ .map(str::to_string)
1039
+ .or_else(|| spec_team_id(&spec));
1040
+ let explicit_team_key = quick_start_requested_team_key(team_id, name).map(str::to_string);
635
1041
  if !fresh {
636
1042
  let state_path = crate::state::persist::runtime_state_path(&workspace);
637
1043
  if state_path.exists() {
638
1044
  let state = crate::state::persist::load_runtime_state(&workspace)
639
1045
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
640
- return Ok(QuickStartReport::ExistingRuntime {
641
- team: team_id.map(str::to_string),
642
- session_name: state
643
- .get("session_name")
644
- .and_then(serde_json::Value::as_str)
645
- .filter(|s| !s.is_empty())
646
- .map(SessionName::new),
647
- state_path: Some(state_path),
648
- next_actions: vec![
649
- "run restart to resume the existing team or pass --fresh to replace it".to_string(),
650
- ],
651
- });
1046
+ if requested_team
1047
+ .as_deref()
1048
+ .is_none_or(|team| runtime_state_has_quick_start_team(&state, team))
1049
+ {
1050
+ return Ok(QuickStartReport::ExistingRuntime {
1051
+ team: requested_team.clone(),
1052
+ session_name: state
1053
+ .get("session_name")
1054
+ .and_then(serde_json::Value::as_str)
1055
+ .filter(|s| !s.is_empty())
1056
+ .map(SessionName::new),
1057
+ state_path: Some(state_path),
1058
+ next_actions: vec![
1059
+ "run restart to resume the existing team or pass --fresh to replace it".to_string(),
1060
+ ],
1061
+ });
1062
+ }
652
1063
  }
653
1064
  }
654
- let mut spec = crate::compiler::compile_team(agents_dir)
655
- .map_err(|e| LifecycleError::Compile(e.to_string()))?;
656
1065
  // CR-040/042: repeated quick-start from one template with distinct --team-id/--name
657
1066
  // must NOT collide on the template-derived tmux session. Override the compiled
658
1067
  // spec's runtime.session_name with one derived from the REQUESTED team identity
659
1068
  // so launch_with_transport (which reads runtime.session_name) spawns into an
660
1069
  // isolated session per requested team.
661
- if let Some(requested) = team_id.or(name).filter(|s| !s.is_empty()) {
1070
+ if let Some(requested) = requested_team.as_deref() {
662
1071
  override_spec_session_name(&mut spec, &format!("team-{requested}"));
663
1072
  }
664
1073
  let spec_path = agents_dir.join("team.spec.yaml");
@@ -670,11 +1079,13 @@ pub fn quick_start_with_transport(
670
1079
  let session_name = spec_session_name(&spec);
671
1080
  let resolved_spec_path = std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
672
1081
  let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
673
- save_launched_team_state(&workspace, &state)?;
1082
+ let state_team_key = explicit_team_key
1083
+ .unwrap_or_else(|| runtime_team_key_for_spec(&resolved_spec_path, &spec, &session_name));
1084
+ save_launched_team_state_for_key(&workspace, &state, Some(&state_team_key))?;
674
1085
  // FIX (rt-host-a real-machine finding): dry_run=false so launch_with_transport calls spawn_agents
675
1086
  // and really creates the tmux session + worker windows (was hardcoded true → never spawned, which
676
1087
  // also starved the coordinator: no session → first tick TmuxSessionMissing → run_daemon loop exits).
677
- let launch = launch_with_transport(&spec_path, false, yes, true, transport)?;
1088
+ let launch = launch_with_transport_in_workspace(&workspace, &spec_path, false, yes, true, transport)?;
678
1089
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
679
1090
  let coordinator_started = crate::coordinator::start_coordinator(&coordinator_workspace)
680
1091
  .map(|report| report.ok)
@@ -684,15 +1095,55 @@ pub fn quick_start_with_transport(
684
1095
  } else {
685
1096
  "coordinator not started"
686
1097
  };
1098
+ // BUG-7: build an honest readiness verdict from the post-spawn runtime state.
1099
+ // - If persist_spawn_agent_state (BUG-2 fix) marked any agent non-running, the
1100
+ // team is observably Degraded.
1101
+ // - Otherwise the framework cannot itself verify that the worker's MCP tool set
1102
+ // loaded successfully (provider-side codex/claude schema rejections happen
1103
+ // asynchronously after spawn), so the verdict is PendingToolLoad — never
1104
+ // bare Ready.
1105
+ let worker_readiness = quick_start_worker_readiness(&workspace);
687
1106
  Ok(QuickStartReport::Ready {
688
1107
  session_name,
689
1108
  launch: Box::new(launch),
690
1109
  next_actions: vec![format!(
691
1110
  "team compiled; real spawn is behind the transport/provider boundary; {coordinator_action}"
692
1111
  )],
1112
+ worker_readiness,
693
1113
  })
694
1114
  }
695
1115
 
1116
+ /// BUG-7 helper: derive a [`QuickStartReadiness`] verdict from the just-written
1117
+ /// runtime state. Reads `agents[*].status`; any non-`running` agent flips the
1118
+ /// verdict to `Degraded { unhealthy_agents }` (sorted, deduped); otherwise
1119
+ /// `PendingToolLoad` — never bare Ready. State read failure is treated as
1120
+ /// PendingToolLoad rather than fabricated success.
1121
+ fn quick_start_worker_readiness(workspace: &Path) -> QuickStartReadiness {
1122
+ let Ok(state) = load_runtime_state(workspace) else {
1123
+ return QuickStartReadiness::PendingToolLoad;
1124
+ };
1125
+ let Some(agents) = state.get("agents").and_then(serde_json::Value::as_object) else {
1126
+ return QuickStartReadiness::PendingToolLoad;
1127
+ };
1128
+ let mut unhealthy: Vec<String> = agents
1129
+ .iter()
1130
+ .filter_map(|(id, agent)| {
1131
+ let status = agent.get("status").and_then(serde_json::Value::as_str);
1132
+ match status {
1133
+ Some("running") => None,
1134
+ _ => Some(id.clone()),
1135
+ }
1136
+ })
1137
+ .collect();
1138
+ if unhealthy.is_empty() {
1139
+ QuickStartReadiness::PendingToolLoad
1140
+ } else {
1141
+ unhealthy.sort();
1142
+ unhealthy.dedup();
1143
+ QuickStartReadiness::Degraded { unhealthy_agents: unhealthy }
1144
+ }
1145
+ }
1146
+
696
1147
  /// `detect_inherited_dangerous_permissions`(`launch/config.py`):扫进程祖先链找
697
1148
  /// `--dangerously-*` flag,产出危险审批继承态。launch 在 inherited=false 且无 --yes 时拒。
698
1149
  pub fn detect_dangerous_approval() -> Result<DangerousApproval, LifecycleError> {
@@ -908,7 +1359,8 @@ pub fn add_agent(
908
1359
  let team_dir = selected
909
1360
  .spec_workspace
910
1361
  .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
911
- add_agent_with_transport(
1362
+ add_agent_with_transport_at_paths(
1363
+ &selected.run_workspace,
912
1364
  &team_dir,
913
1365
  agent_id,
914
1366
  role_file_path,
@@ -930,11 +1382,31 @@ pub fn add_agent_with_transport(
930
1382
  ) -> Result<AddAgentReport, LifecycleError> {
931
1383
  let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
932
1384
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1385
+ add_agent_with_transport_at_paths(
1386
+ &run_workspace,
1387
+ workspace,
1388
+ agent_id,
1389
+ role_file_path,
1390
+ open_display,
1391
+ team,
1392
+ transport,
1393
+ )
1394
+ }
1395
+
1396
+ fn add_agent_with_transport_at_paths(
1397
+ run_workspace: &Path,
1398
+ team_dir: &Path,
1399
+ agent_id: &AgentId,
1400
+ role_file_path: &Path,
1401
+ open_display: bool,
1402
+ team: Option<&str>,
1403
+ transport: &dyn Transport,
1404
+ ) -> Result<AddAgentReport, LifecycleError> {
933
1405
  let owner_state = if team.is_some() {
934
- crate::state::projection::select_runtime_state(&run_workspace, team)
1406
+ crate::state::projection::select_runtime_state(run_workspace, team)
935
1407
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
936
1408
  } else {
937
- load_runtime_state(&run_workspace).map_err(|e| LifecycleError::StatePersist(e.to_string()))?
1409
+ load_runtime_state(run_workspace).map_err(|e| LifecycleError::StatePersist(e.to_string()))?
938
1410
  };
939
1411
  ensure_owner_allowed_for_state(&owner_state, Some(agent_id))?;
940
1412
  if !role_file_path.exists() {
@@ -943,7 +1415,6 @@ pub fn add_agent_with_transport(
943
1415
  role_file_path.display()
944
1416
  )));
945
1417
  }
946
- let team_dir = workspace;
947
1418
  if agent_id_exists_in_team_dir(team_dir, agent_id) {
948
1419
  return Err(LifecycleError::RequirementUnmet(format!(
949
1420
  "agent id already exists: {agent_id}"
@@ -958,10 +1429,9 @@ pub fn add_agent_with_transport(
958
1429
  })?;
959
1430
  let (meta, _) = crate::compiler::read_front_matter(&dynamic_role_file)
960
1431
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
961
- let run_ws = team_workspace(team_dir);
962
- upsert_agent_state_from_role(&run_ws, agent_id, &meta, &dynamic_role_file)?;
1432
+ upsert_agent_state_from_role(run_workspace, team, agent_id, &meta, &dynamic_role_file)?;
963
1433
  let started = crate::lifecycle::restart::start_agent_at_paths(
964
- &run_ws,
1434
+ run_workspace,
965
1435
  team_dir,
966
1436
  agent_id,
967
1437
  false,
@@ -990,12 +1460,18 @@ pub fn add_agent_with_transport(
990
1460
 
991
1461
  fn upsert_agent_state_from_role(
992
1462
  workspace: &Path,
1463
+ team: Option<&str>,
993
1464
  agent_id: &AgentId,
994
1465
  meta: &Value,
995
1466
  dynamic_role_file: &Path,
996
1467
  ) -> Result<(), LifecycleError> {
997
- let mut state = crate::state::persist::load_runtime_state(workspace)
998
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1468
+ let mut state = if team.is_some() {
1469
+ crate::state::projection::select_runtime_state(workspace, team)
1470
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
1471
+ } else {
1472
+ crate::state::persist::load_runtime_state(workspace)
1473
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?
1474
+ };
999
1475
  if !state.is_object() {
1000
1476
  state = serde_json::json!({});
1001
1477
  }
@@ -1040,7 +1516,13 @@ fn upsert_agent_state_from_role(
1040
1516
  }
1041
1517
  }
1042
1518
  agent_map.insert(agent_id.as_str().to_string(), entry);
1043
- save_runtime_state(workspace, &state).map_err(|e| LifecycleError::StatePersist(e.to_string()))
1519
+ if team.is_some() {
1520
+ let team_key = explicit_active_team_key(&state)
1521
+ .or_else(|| team.filter(|key| !key.is_empty()).map(str::to_string));
1522
+ save_launched_team_state_for_key(workspace, &state, team_key.as_deref())
1523
+ } else {
1524
+ save_runtime_state(workspace, &state).map_err(|e| LifecycleError::StatePersist(e.to_string()))
1525
+ }
1044
1526
  }
1045
1527
 
1046
1528
  fn materialize_added_role_file(
@@ -1571,10 +2053,7 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
1571
2053
  } else {
1572
2054
  caller.provider
1573
2055
  };
1574
- let pane_id = std::env::var("TMUX_PANE")
1575
- .ok()
1576
- .filter(|pane| !pane.is_empty())
1577
- .unwrap_or(caller.pane_id);
2056
+ let pane_id = caller.pane_id;
1578
2057
  if pane_id.is_empty() {
1579
2058
  return false;
1580
2059
  }
@@ -1596,10 +2075,18 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
1596
2075
  "status": "attached",
1597
2076
  "provider": provider,
1598
2077
  "pane_id": owner.get("pane_id").cloned().unwrap_or(serde_json::Value::Null),
2078
+ "pane": owner.get("pane_id").cloned().unwrap_or(serde_json::Value::Null),
1599
2079
  "leader_session_uuid": owner.get("leader_session_uuid").cloned().unwrap_or(serde_json::Value::Null),
1600
2080
  "owner_epoch": owner_epoch,
1601
2081
  "discovery": "quick_start",
1602
2082
  });
2083
+ let mut receiver = receiver;
2084
+ if let (Some(receiver), Some(socket)) = (
2085
+ receiver.as_object_mut(),
2086
+ crate::tmux_backend::socket_name_from_tmux_env(),
2087
+ ) {
2088
+ receiver.insert("tmux_socket".to_string(), serde_json::json!(socket));
2089
+ }
1603
2090
  if let Some(obj) = state.as_object_mut() {
1604
2091
  obj.insert("leader_receiver".to_string(), receiver);
1605
2092
  obj.insert("team_owner".to_string(), owner);
@@ -1807,10 +2294,12 @@ fn write_launch_permission_audit(
1807
2294
  }
1808
2295
 
1809
2296
  fn team_workspace(team_dir: &Path) -> PathBuf {
1810
- team_dir
1811
- .parent()
1812
- .map(Path::to_path_buf)
1813
- .unwrap_or_else(|| team_dir.to_path_buf())
2297
+ crate::model::paths::team_workspace(team_dir).unwrap_or_else(|_| {
2298
+ team_dir
2299
+ .parent()
2300
+ .map(Path::to_path_buf)
2301
+ .unwrap_or_else(|| team_dir.to_path_buf())
2302
+ })
1814
2303
  }
1815
2304
 
1816
2305
  fn agent_id_exists_in_team_dir(team_dir: &Path, agent_id: &AgentId) -> bool {