@team-agent/installer 0.3.1 → 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.
@@ -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, &session_name, transport, &started)?;
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 {
@@ -135,13 +155,12 @@ fn spawn_agents(
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();
137
157
  let mcp_team_id =
138
- runtime_active_team_key_for_spawn(&workspace, spec_path, spec, session_name);
139
- let process_team_id = process_team_id_for_spawn(&workspace, spec);
158
+ runtime_active_team_key_for_spawn(workspace, spec_path, spec, session_name);
140
159
  let mcp_config = adapter
141
160
  .mcp_config(auth_mode)
142
161
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
143
- let mcp_config = resolve_mcp_config(mcp_config, &workspace, agent_id_raw, &mcp_team_id);
144
- let mcp_config_path = write_worker_mcp_config(&workspace, agent_id_raw, &mcp_config)?;
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)?;
145
164
  let mut argv = adapter
146
165
  .build_command_with_tools(
147
166
  auth_mode,
@@ -154,15 +173,15 @@ fn spawn_agents(
154
173
  point_native_mcp_config_at_file(&mut argv, provider, &mcp_config_path);
155
174
  fill_spawn_placeholders_full(
156
175
  &mut argv,
157
- &workspace,
176
+ workspace,
158
177
  agent_id_raw,
159
- process_team_id.as_deref(),
178
+ Some(&mcp_team_id),
160
179
  );
161
180
  let window = WindowName::new(agent_id_raw);
162
181
  let env = inherited_env_with_team_overrides(
163
- &workspace,
182
+ workspace,
164
183
  agent_id_raw,
165
- process_team_id.as_deref(),
184
+ Some(&mcp_team_id),
166
185
  );
167
186
  let spawn = if started.is_empty() {
168
187
  transport.spawn_first(session_name, &window, &argv, team_dir, &env)
@@ -194,15 +213,14 @@ fn spawn_agents(
194
213
  }
195
214
 
196
215
  fn persist_spawn_agent_state(
216
+ workspace: &Path,
197
217
  spec_path: &Path,
198
218
  spec: &Value,
199
219
  session_name: &SessionName,
200
220
  transport: &dyn Transport,
201
221
  started: &[StartedAgent],
202
222
  ) -> Result<(), LifecycleError> {
203
- let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
204
- let workspace = team_workspace(team_dir);
205
- let state_path = crate::state::persist::runtime_state_path(&workspace);
223
+ let state_path = crate::state::persist::runtime_state_path(workspace);
206
224
  let mut state = if state_path.exists() {
207
225
  let text = std::fs::read_to_string(&state_path)
208
226
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", state_path.display())))?;
@@ -213,7 +231,7 @@ fn persist_spawn_agent_state(
213
231
  };
214
232
  let team_id = explicit_active_team_key(&state)
215
233
  .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name));
216
- let worker_tmux_socket = launched_worker_tmux_socket(transport, &workspace);
234
+ let worker_tmux_socket = launched_worker_tmux_socket(transport, workspace);
217
235
  drop_worker_pane_seeded_owner(
218
236
  &mut state,
219
237
  &team_id,
@@ -231,6 +249,7 @@ fn persist_spawn_agent_state(
231
249
  .iter()
232
250
  .map(|agent| agent.agent_id.as_str().to_string())
233
251
  .collect();
252
+ let pane_pids_by_agent = pane_pids_by_started_agent(transport, started);
234
253
  let mut agents = serde_json::Map::new();
235
254
  let spawned_at = spawn_timestamp();
236
255
  for agent in spec_agent_values(spec) {
@@ -265,9 +284,10 @@ fn persist_spawn_agent_state(
265
284
  agents.insert(id.to_string(), serde_json::Value::Object(failed));
266
285
  continue;
267
286
  }
287
+ let pane_pid = pane_pids_by_agent.get(id).copied();
268
288
  agents.insert(
269
289
  id.to_string(),
270
- running_agent_state(agent, id, provider, &workspace, &spawned_at, &team_id)?,
290
+ running_agent_state(agent, id, provider, workspace, &spawned_at, &team_id, pane_pid)?,
271
291
  );
272
292
  }
273
293
  if let Some(obj) = state.as_object_mut() {
@@ -277,21 +297,108 @@ fn persist_spawn_agent_state(
277
297
  obj.insert("agents".to_string(), serde_json::Value::Object(agents));
278
298
  state = serde_json::Value::Object(obj);
279
299
  }
280
- 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()
281
318
  }
282
319
 
283
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> {
284
329
  let existing = load_runtime_state(workspace).unwrap_or_else(|_| serde_json::json!({}));
285
- 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));
286
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
+ }
287
341
  promote_launched_binding_from_team_entry(&mut launched, &launched_key);
288
342
  drop_foreign_seeded_owner(&existing, &launched_key, &mut launched);
289
- 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
+ };
290
348
  let mut projected = crate::state::projection::project_top_level_view(&merged, &launched_key);
291
349
  drop_unbound_top_level_owner(&mut projected);
292
350
  save_runtime_state(workspace, &projected).map_err(|e| LifecycleError::StatePersist(e.to_string()))
293
351
  }
294
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
+
295
402
  fn promote_launched_binding_from_team_entry(launched: &mut serde_json::Value, launched_key: &str) {
296
403
  let entry = launched
297
404
  .get("teams")
@@ -448,6 +555,7 @@ fn seed_unbound_launched_owner(launched: &mut serde_json::Value, launched_key: &
448
555
  "status": "attached",
449
556
  "provider": provider,
450
557
  "pane_id": "__team_agent_unbound__",
558
+ "pane": "__team_agent_unbound__",
451
559
  "leader_session_uuid": uuid.as_str(),
452
560
  "owner_epoch": owner_epoch,
453
561
  "discovery": "quick_start",
@@ -482,6 +590,7 @@ fn running_agent_state(
482
590
  workspace: &Path,
483
591
  spawned_at: &str,
484
592
  team_id: &str,
593
+ pane_pid: Option<u32>,
485
594
  ) -> Result<serde_json::Value, LifecycleError> {
486
595
  let model = agent.get("model").and_then(Value::as_str);
487
596
  let auth_mode = agent
@@ -523,6 +632,9 @@ fn running_agent_state(
523
632
  serde_json::json!(workspace.to_string_lossy().to_string()),
524
633
  );
525
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
+ }
526
638
  Ok(serde_json::Value::Object(state))
527
639
  }
528
640
 
@@ -769,13 +881,6 @@ fn runtime_active_team_key_for_spawn(
769
881
  .unwrap_or_else(|| runtime_team_key_for_spec(spec_path, spec, session_name))
770
882
  }
771
883
 
772
- fn process_team_id_for_spawn(workspace: &Path, spec: &Value) -> Option<String> {
773
- load_runtime_state(workspace)
774
- .ok()
775
- .and_then(|state| explicit_active_team_key(&state))
776
- .or_else(|| spec_team_id(spec))
777
- }
778
-
779
884
  fn explicit_active_team_key(state: &serde_json::Value) -> Option<String> {
780
885
  state
781
886
  .get("active_team_key")
@@ -824,6 +929,43 @@ fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
824
929
  }
825
930
  }
826
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
+
827
969
  /// `quick_start(agents_dir, name, yes, fresh, team_id)`(`diagnose/quick_start.py:18`)。
828
970
  /// 面向用户的零配置入口:编译 team_dir → `launch` → autobind leader receiver → 起
829
971
  /// coordinator → `wait_ready` 轮询就绪。归入 lifecycle module(不与 diagnose 混)。
@@ -834,14 +976,29 @@ pub fn quick_start(
834
976
  fresh: bool,
835
977
  team_id: Option<&str>,
836
978
  ) -> Result<QuickStartReport, LifecycleError> {
837
- 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,
838
995
  agents_dir,
839
996
  name,
840
997
  yes,
841
998
  fresh,
842
999
  team_id,
843
- // CP-1: per-team socket bound to the run workspace (team_workspace(agents_dir)).
844
- &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),
845
1002
  )
846
1003
  }
847
1004
 
@@ -854,6 +1011,19 @@ pub fn quick_start_with_transport(
854
1011
  fresh: bool,
855
1012
  team_id: Option<&str>,
856
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,
857
1027
  ) -> Result<QuickStartReport, LifecycleError> {
858
1028
  if !agents_dir.exists() {
859
1029
  return Err(LifecycleError::Compile(format!(
@@ -861,34 +1031,43 @@ pub fn quick_start_with_transport(
861
1031
  agents_dir.display()
862
1032
  )));
863
1033
  }
864
- 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);
865
1041
  if !fresh {
866
1042
  let state_path = crate::state::persist::runtime_state_path(&workspace);
867
1043
  if state_path.exists() {
868
1044
  let state = crate::state::persist::load_runtime_state(&workspace)
869
1045
  .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
870
- return Ok(QuickStartReport::ExistingRuntime {
871
- team: team_id.map(str::to_string),
872
- session_name: state
873
- .get("session_name")
874
- .and_then(serde_json::Value::as_str)
875
- .filter(|s| !s.is_empty())
876
- .map(SessionName::new),
877
- state_path: Some(state_path),
878
- next_actions: vec![
879
- "run restart to resume the existing team or pass --fresh to replace it".to_string(),
880
- ],
881
- });
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
+ }
882
1063
  }
883
1064
  }
884
- let mut spec = crate::compiler::compile_team(agents_dir)
885
- .map_err(|e| LifecycleError::Compile(e.to_string()))?;
886
1065
  // CR-040/042: repeated quick-start from one template with distinct --team-id/--name
887
1066
  // must NOT collide on the template-derived tmux session. Override the compiled
888
1067
  // spec's runtime.session_name with one derived from the REQUESTED team identity
889
1068
  // so launch_with_transport (which reads runtime.session_name) spawns into an
890
1069
  // isolated session per requested team.
891
- if let Some(requested) = team_id.or(name).filter(|s| !s.is_empty()) {
1070
+ if let Some(requested) = requested_team.as_deref() {
892
1071
  override_spec_session_name(&mut spec, &format!("team-{requested}"));
893
1072
  }
894
1073
  let spec_path = agents_dir.join("team.spec.yaml");
@@ -900,11 +1079,13 @@ pub fn quick_start_with_transport(
900
1079
  let session_name = spec_session_name(&spec);
901
1080
  let resolved_spec_path = std::fs::canonicalize(&spec_path).unwrap_or_else(|_| spec_path.clone());
902
1081
  let state = initial_runtime_state(&spec, &resolved_spec_path, &workspace, agents_dir);
903
- 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))?;
904
1085
  // FIX (rt-host-a real-machine finding): dry_run=false so launch_with_transport calls spawn_agents
905
1086
  // and really creates the tmux session + worker windows (was hardcoded true → never spawned, which
906
1087
  // also starved the coordinator: no session → first tick TmuxSessionMissing → run_daemon loop exits).
907
- 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)?;
908
1089
  let coordinator_workspace = crate::coordinator::WorkspacePath::new(workspace.clone());
909
1090
  let coordinator_started = crate::coordinator::start_coordinator(&coordinator_workspace)
910
1091
  .map(|report| report.ok)
@@ -1178,7 +1359,8 @@ pub fn add_agent(
1178
1359
  let team_dir = selected
1179
1360
  .spec_workspace
1180
1361
  .ok_or_else(|| LifecycleError::TeamSelect("active team spec workspace not found".to_string()))?;
1181
- add_agent_with_transport(
1362
+ add_agent_with_transport_at_paths(
1363
+ &selected.run_workspace,
1182
1364
  &team_dir,
1183
1365
  agent_id,
1184
1366
  role_file_path,
@@ -1200,11 +1382,31 @@ pub fn add_agent_with_transport(
1200
1382
  ) -> Result<AddAgentReport, LifecycleError> {
1201
1383
  let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
1202
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> {
1203
1405
  let owner_state = if team.is_some() {
1204
- crate::state::projection::select_runtime_state(&run_workspace, team)
1406
+ crate::state::projection::select_runtime_state(run_workspace, team)
1205
1407
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?
1206
1408
  } else {
1207
- 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()))?
1208
1410
  };
1209
1411
  ensure_owner_allowed_for_state(&owner_state, Some(agent_id))?;
1210
1412
  if !role_file_path.exists() {
@@ -1213,7 +1415,6 @@ pub fn add_agent_with_transport(
1213
1415
  role_file_path.display()
1214
1416
  )));
1215
1417
  }
1216
- let team_dir = workspace;
1217
1418
  if agent_id_exists_in_team_dir(team_dir, agent_id) {
1218
1419
  return Err(LifecycleError::RequirementUnmet(format!(
1219
1420
  "agent id already exists: {agent_id}"
@@ -1228,10 +1429,9 @@ pub fn add_agent_with_transport(
1228
1429
  })?;
1229
1430
  let (meta, _) = crate::compiler::read_front_matter(&dynamic_role_file)
1230
1431
  .map_err(|e| LifecycleError::Compile(e.to_string()))?;
1231
- let run_ws = team_workspace(team_dir);
1232
- 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)?;
1233
1433
  let started = crate::lifecycle::restart::start_agent_at_paths(
1234
- &run_ws,
1434
+ run_workspace,
1235
1435
  team_dir,
1236
1436
  agent_id,
1237
1437
  false,
@@ -1260,12 +1460,18 @@ pub fn add_agent_with_transport(
1260
1460
 
1261
1461
  fn upsert_agent_state_from_role(
1262
1462
  workspace: &Path,
1463
+ team: Option<&str>,
1263
1464
  agent_id: &AgentId,
1264
1465
  meta: &Value,
1265
1466
  dynamic_role_file: &Path,
1266
1467
  ) -> Result<(), LifecycleError> {
1267
- let mut state = crate::state::persist::load_runtime_state(workspace)
1268
- .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
+ };
1269
1475
  if !state.is_object() {
1270
1476
  state = serde_json::json!({});
1271
1477
  }
@@ -1310,7 +1516,13 @@ fn upsert_agent_state_from_role(
1310
1516
  }
1311
1517
  }
1312
1518
  agent_map.insert(agent_id.as_str().to_string(), entry);
1313
- 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
+ }
1314
1526
  }
1315
1527
 
1316
1528
  fn materialize_added_role_file(
@@ -1863,6 +2075,7 @@ fn seed_launched_owner_from_env(state: &mut serde_json::Value) -> bool {
1863
2075
  "status": "attached",
1864
2076
  "provider": provider,
1865
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),
1866
2079
  "leader_session_uuid": owner.get("leader_session_uuid").cloned().unwrap_or(serde_json::Value::Null),
1867
2080
  "owner_epoch": owner_epoch,
1868
2081
  "discovery": "quick_start",
@@ -160,6 +160,71 @@ pub(super) fn agent_rollout_path(agent: &serde_json::Value) -> Option<RolloutPat
160
160
  .map(RolloutPath::new)
161
161
  }
162
162
 
163
+ pub(crate) fn refresh_missing_provider_sessions(
164
+ state: &mut serde_json::Value,
165
+ ) -> Result<bool, LifecycleError> {
166
+ let Some(agents) = state.get_mut("agents").and_then(serde_json::Value::as_object_mut) else {
167
+ return Ok(false);
168
+ };
169
+ let mut changed = false;
170
+ for (agent_id, agent) in agents {
171
+ let Some(agent_obj) = agent.as_object_mut() else {
172
+ continue;
173
+ };
174
+ if agent_obj
175
+ .get("session_id")
176
+ .and_then(serde_json::Value::as_str)
177
+ .is_some_and(|session| !session.is_empty())
178
+ {
179
+ continue;
180
+ }
181
+ let Some(spawn_cwd) = agent_obj
182
+ .get("spawn_cwd")
183
+ .and_then(serde_json::Value::as_str)
184
+ .filter(|cwd| !cwd.is_empty())
185
+ else {
186
+ continue;
187
+ };
188
+ let provider = agent_provider(&serde_json::Value::Object(agent_obj.clone()));
189
+ let adapter = crate::provider::get_adapter(provider);
190
+ let captured = adapter
191
+ .capture_session_id(agent_id, Path::new(spawn_cwd), 0)
192
+ .map_err(|e| LifecycleError::Provider(e.to_string()))?;
193
+ let Some(captured) = captured else {
194
+ continue;
195
+ };
196
+ if let Some(session_id) = captured.session_id {
197
+ agent_obj.insert(
198
+ "session_id".to_string(),
199
+ serde_json::json!(session_id.as_str()),
200
+ );
201
+ changed = true;
202
+ }
203
+ if let Some(rollout_path) = captured.rollout_path {
204
+ agent_obj.insert(
205
+ "rollout_path".to_string(),
206
+ serde_json::json!(rollout_path.as_path().to_string_lossy()),
207
+ );
208
+ changed = true;
209
+ }
210
+ agent_obj.insert(
211
+ "captured_at".to_string(),
212
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
213
+ );
214
+ agent_obj.insert(
215
+ "captured_via".to_string(),
216
+ serde_json::to_value(captured.captured_via)
217
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
218
+ );
219
+ agent_obj.insert(
220
+ "attribution_confidence".to_string(),
221
+ serde_json::to_value(captured.attribution_confidence)
222
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
223
+ );
224
+ }
225
+ Ok(changed)
226
+ }
227
+
163
228
  /// Tools list off an agent's runtime state entry (`tools: [...]`). Restart paths
164
229
  /// don't have the full spec object, only the runtime state — so they read tools from
165
230
  /// the state row, falling back to an empty list. Contract C requires the worker