@team-agent/installer 0.5.100 → 0.5.101

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 (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +29 -7
  4. package/crates/team-agent/src/cli/mod.rs +24 -8
  5. package/crates/team-agent/src/compiler.rs +3 -0
  6. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +211 -16
  7. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +1 -0
  8. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +15 -5
  9. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +89 -6
  10. package/crates/team-agent/src/lifecycle/launch/role_source.rs +168 -32
  11. package/crates/team-agent/src/lifecycle/launch.rs +11 -3
  12. package/crates/team-agent/src/lifecycle/profile_launch.rs +23 -8
  13. package/crates/team-agent/src/lifecycle/restart/agent.rs +2 -2
  14. package/crates/team-agent/src/lifecycle/restart/avatar.rs +1806 -0
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +114 -1
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +8 -2
  17. package/crates/team-agent/src/lifecycle/restart/selection.rs +14 -2
  18. package/crates/team-agent/src/lifecycle/restart.rs +11 -4
  19. package/crates/team-agent/src/lifecycle/tests/pi_fork_session_inheritance_red.rs +1398 -0
  20. package/crates/team-agent/src/lifecycle/tests.rs +1 -0
  21. package/crates/team-agent/src/lifecycle/types.rs +10 -2
  22. package/crates/team-agent/src/model/spec.rs +1 -0
  23. package/crates/team-agent/src/provider/types.rs +2 -0
  24. package/crates/team-agent/src/state/persist.rs +58 -2
  25. package/crates/team-agent/src/state/projection.rs +5 -1
  26. package/crates/team-agent/src/state/repository.rs +125 -4
  27. package/crates/team-agent/src/transport/test_support.rs +23 -3
  28. package/package.json +4 -4
  29. package/skills/team-agent/SKILL.md +17 -46
  30. package/skills/team-agent/references/team-agent-operator.md +4 -2
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.100"
584
+ version = "0.5.101"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.100"
12
+ version = "0.5.101"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -94,6 +94,14 @@ pub(crate) fn classify_tmux_server_error(error_text: &str) -> &'static str {
94
94
  }
95
95
 
96
96
  pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value, Value) {
97
+ diagnose_runtime_with_workspace(None, state, backend)
98
+ }
99
+
100
+ fn diagnose_runtime_with_workspace(
101
+ workspace: Option<&std::path::Path>,
102
+ state: &Value,
103
+ backend: &dyn Transport,
104
+ ) -> (Value, Value) {
97
105
  let mut issues = Vec::new();
98
106
  let mut repairs = Vec::new();
99
107
 
@@ -248,12 +256,25 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
248
256
  .and_then(Value::as_str)
249
257
  .filter(|path| !path.is_empty())
250
258
  .map(crate::provider::RolloutPath::new);
251
- let identity_probe =
252
- crate::lifecycle::restart::session_identity_probe_for_agent(
253
- &crate::model::ids::AgentId::new(agent_id.clone()),
254
- provider,
255
- rollout_path.as_ref(),
256
- );
259
+ let agent_identity = crate::model::ids::AgentId::new(agent_id.clone());
260
+ let identity_probe = workspace
261
+ .map(|workspace| {
262
+ crate::lifecycle::restart::session_identity_probe_for_agent_with_fork_ancestry(
263
+ workspace,
264
+ state,
265
+ &agent_identity,
266
+ agent_state,
267
+ provider,
268
+ rollout_path.as_ref(),
269
+ )
270
+ })
271
+ .unwrap_or_else(|| {
272
+ crate::lifecycle::restart::session_identity_probe_for_agent(
273
+ &agent_identity,
274
+ provider,
275
+ rollout_path.as_ref(),
276
+ )
277
+ });
257
278
  if identity_probe.identity_ok == Some(false) {
258
279
  let issue = format!("session_identity_mismatch:{agent_id}");
259
280
  issues.push(json!(issue));
@@ -304,7 +325,8 @@ pub(crate) fn diagnose_runtime_for_workspace(
304
325
  selected_team_key: Option<&str>,
305
326
  coordinator_health: &crate::coordinator::HealthReport,
306
327
  ) -> (Value, Value) {
307
- let (mut issues, mut repairs) = diagnose_runtime(state, backend);
328
+ let (mut issues, mut repairs) =
329
+ diagnose_runtime_with_workspace(Some(workspace), state, backend);
308
330
  append_live_leader_workspace_mismatch_issue(
309
331
  workspace,
310
332
  state,
@@ -2926,14 +2926,30 @@ pub mod lifecycle_port {
2926
2926
  let source = crate::model::ids::AgentId::new(source_agent);
2927
2927
  let dest = crate::model::ids::AgentId::new(as_agent_id);
2928
2928
  match crate::lifecycle::fork_agent(workspace, &source, &dest, label, true, team) {
2929
- Ok(report) => Ok(json!({
2930
- "ok": true,
2931
- "source_agent_id": report.source_agent_id.as_str(),
2932
- "new_agent_id": report.new_agent_id.as_str(),
2933
- "session_id": report.session_id.as_ref().map(|session| session.as_str()),
2934
- "new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
2935
- "backing_state": report.backing_state,
2936
- })),
2929
+ Ok(report) => {
2930
+ let mut value = json!({
2931
+ "ok": true,
2932
+ "source_agent_id": report.source_agent_id.as_str(),
2933
+ "new_agent_id": report.new_agent_id.as_str(),
2934
+ "session_id": report.session_id.as_ref().map(|session| session.as_str()),
2935
+ "new_session_id": report.session_id.as_ref().map(|session| session.as_str()),
2936
+ "backing_state": report.backing_state,
2937
+ });
2938
+ if let (Some(details), Some(object)) = (report.pi_fork, value.as_object_mut()) {
2939
+ object.insert("status".to_string(), json!("forked"));
2940
+ object.insert("target".to_string(), json!(report.new_agent_id.as_str()));
2941
+ object.insert(
2942
+ "source_session_id".to_string(),
2943
+ json!(details.source_session_id.as_str()),
2944
+ );
2945
+ object.insert("session_id".to_string(), json!(details.session_id.as_str()));
2946
+ object.insert(
2947
+ "backing_path".to_string(),
2948
+ json!(details.backing_path.0.to_string_lossy().to_string()),
2949
+ );
2950
+ }
2951
+ Ok(value)
2952
+ }
2937
2953
  Err(e) => Ok(error_value(e)),
2938
2954
  }
2939
2955
  }
@@ -555,6 +555,9 @@ fn compile_role_agent_with_mode(
555
555
  ]),
556
556
  ),
557
557
  ];
558
+ if let Some(label) = string_field(&meta, "label") {
559
+ agent_items.insert(1, ("label", Value::Str(label)));
560
+ }
558
561
  if let Some(profile) = string_field(&meta, "profile") {
559
562
  agent_items.push(("profile", Value::Str(profile)));
560
563
  }
@@ -177,6 +177,216 @@ pub(super) fn upsert_agent_state_from_role(
177
177
  "runtime state agents is not an object".to_string(),
178
178
  ));
179
179
  };
180
+ let entry = starting_agent_entry(workspace, agent_id, meta, dynamic_role_file);
181
+ agent_map.insert(agent_id.as_str().to_string(), entry);
182
+ crate::lifecycle::restart::remove::clear_agent_retirement_in_state(&mut state, agent_id);
183
+ save_launched_team_state_for_key(
184
+ workspace,
185
+ &state,
186
+ Some(canonical_team_key),
187
+ Some(agent_id.as_str()),
188
+ )
189
+ }
190
+
191
+ /// ---
192
+ /// purpose: 首次登记 Pi fork 席位,原子带入目标角色与完整 fork capture seed
193
+ /// returns: 成功后 TARGET 已按 ForkAgent typed intent 登记为 starting
194
+ /// errors: 重复席位、无效 capture seed 或 state 写入失败时拒绝
195
+ /// ---
196
+ pub(crate) fn fork_upsert_agent_state_from_role(
197
+ workspace: &Path,
198
+ canonical_team_key: &str,
199
+ agent_id: &AgentId,
200
+ meta: &Value,
201
+ compiled_agent: &Value,
202
+ dynamic_role_file: &Path,
203
+ seed: &ForkCaptureSeed,
204
+ ) -> Result<(), LifecycleError> {
205
+ if agent_id == &seed.source_agent_id {
206
+ return Err(LifecycleError::RequirementUnmet(
207
+ "fork target must differ from source".to_string(),
208
+ ));
209
+ }
210
+ if seed.captured.captured_via != crate::provider::CaptureVia::ForkSnapshot
211
+ || seed.captured.attribution_confidence != crate::provider::Confidence::High
212
+ || seed.captured.session_id.is_none()
213
+ || seed.captured.rollout_path.is_none()
214
+ || seed.captured_at.is_empty()
215
+ {
216
+ return Err(LifecycleError::RequirementUnmet(
217
+ "fork capture seed is incomplete or not a verified Pi snapshot".to_string(),
218
+ ));
219
+ }
220
+ if compiled_agent.get("id").and_then(Value::as_str) != Some(agent_id.as_str()) {
221
+ return Err(LifecycleError::Compile(
222
+ "compiled fork role id does not match target".to_string(),
223
+ ));
224
+ }
225
+ let sessions_root = std::fs::canonicalize(&seed.pi_sessions_root)
226
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
227
+ let backing = seed.captured.rollout_path.as_ref().ok_or_else(|| {
228
+ LifecycleError::RequirementUnmet("fork backing path is missing".to_string())
229
+ })?;
230
+ let backing_path = std::fs::canonicalize(&backing.0)
231
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
232
+ if !backing_path.starts_with(&sessions_root) {
233
+ return Err(LifecycleError::RequirementUnmet(
234
+ "fork backing is outside the target Pi session root".to_string(),
235
+ ));
236
+ }
237
+
238
+ let mut state =
239
+ crate::state::projection::select_runtime_state(workspace, Some(canonical_team_key))
240
+ .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
241
+ if !state.is_object() {
242
+ state = serde_json::json!({});
243
+ }
244
+ let root = state.as_object_mut().ok_or_else(|| {
245
+ LifecycleError::StatePersist("runtime state root is not an object".to_string())
246
+ })?;
247
+ let agents = root
248
+ .entry("agents".to_string())
249
+ .or_insert_with(|| serde_json::json!({}));
250
+ if !agents.is_object() {
251
+ *agents = serde_json::json!({});
252
+ }
253
+ let agent_map = agents.as_object_mut().ok_or_else(|| {
254
+ LifecycleError::StatePersist("runtime state agents is not an object".to_string())
255
+ })?;
256
+ if agent_map.contains_key(agent_id.as_str()) {
257
+ return Err(LifecycleError::RequirementUnmet(format!(
258
+ "agent id already exists in selected team: {}",
259
+ agent_id
260
+ )));
261
+ }
262
+
263
+ let source_profile_dir = agent_map
264
+ .get(seed.source_agent_id.as_str())
265
+ .and_then(serde_json::Value::as_object)
266
+ .and_then(|source| source.get("_profile_dir"))
267
+ .and_then(serde_json::Value::as_str)
268
+ .filter(|path| !path.is_empty())
269
+ .map(|path| serde_json::json!(path));
270
+ let mut entry = starting_agent_entry(workspace, agent_id, meta, dynamic_role_file);
271
+ let entry_object = entry.as_object_mut().ok_or_else(|| {
272
+ LifecycleError::StatePersist("fork target entry is not an object".to_string())
273
+ })?;
274
+ for field in [
275
+ "provider",
276
+ "auth_mode",
277
+ "role",
278
+ "model",
279
+ "effort",
280
+ "profile",
281
+ "tools",
282
+ "system_prompt",
283
+ "output_contract",
284
+ "communication_mode",
285
+ "working_directory",
286
+ "cwd",
287
+ "config",
288
+ "name",
289
+ "dangerously_skip_permissions",
290
+ ] {
291
+ if let Some(value) = compiled_agent.get(field) {
292
+ entry_object.insert(field.to_string(), yaml_value_to_json(value));
293
+ }
294
+ }
295
+ if compiled_agent.get("model").is_some() && !entry_object.contains_key("model_source") {
296
+ entry_object.insert("model_source".to_string(), serde_json::json!("team"));
297
+ }
298
+ entry_object.insert("agent_id".to_string(), serde_json::json!(agent_id.as_str()));
299
+ entry_object.insert("label".to_string(), serde_json::json!(agent_id.as_str()));
300
+ entry_object.insert(
301
+ "owner_team_id".to_string(),
302
+ serde_json::json!(canonical_team_key),
303
+ );
304
+ entry_object.insert(
305
+ "claude_projects_root".to_string(),
306
+ serde_json::json!(sessions_root.to_string_lossy().to_string()),
307
+ );
308
+ entry_object.insert(
309
+ "forked_from".to_string(),
310
+ serde_json::json!(seed.source_agent_id.as_str()),
311
+ );
312
+ entry_object.insert(
313
+ "spawn_cwd".to_string(),
314
+ serde_json::json!(seed.captured.spawn_cwd.to_string_lossy().to_string()),
315
+ );
316
+ entry_object.insert(
317
+ "session_id".to_string(),
318
+ serde_json::json!(seed.captured.session_id.as_ref().map(|id| id.as_str())),
319
+ );
320
+ let backing = seed.captured.rollout_path.as_ref().ok_or_else(|| {
321
+ LifecycleError::RequirementUnmet("fork backing path is missing".to_string())
322
+ })?;
323
+ entry_object.insert(
324
+ "rollout_path".to_string(),
325
+ serde_json::json!(backing.0.to_string_lossy().to_string()),
326
+ );
327
+ entry_object.insert(
328
+ "captured_at".to_string(),
329
+ serde_json::json!(seed.captured_at.as_str()),
330
+ );
331
+ entry_object.insert(
332
+ "captured_via".to_string(),
333
+ serde_json::to_value(seed.captured.captured_via)
334
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
335
+ );
336
+ entry_object.insert(
337
+ "attribution_confidence".to_string(),
338
+ serde_json::to_value(seed.captured.attribution_confidence)
339
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?,
340
+ );
341
+ entry_object.insert("capture_state".to_string(), serde_json::json!("captured"));
342
+ if let Some(profile_dir) = source_profile_dir {
343
+ entry_object.insert("_profile_dir".to_string(), profile_dir);
344
+ } else {
345
+ entry_object.insert(
346
+ "_profile_dir".to_string(),
347
+ serde_json::json!(seed.profile_dir.to_string_lossy().to_string()),
348
+ );
349
+ }
350
+ agent_map.insert(agent_id.as_str().to_string(), entry);
351
+ crate::lifecycle::restart::remove::clear_agent_retirement_in_state(&mut state, agent_id);
352
+ crate::state::repository::StateRepository::new(workspace)
353
+ .save(
354
+ crate::state::repository::StateWriteIntent::ForkAgent {
355
+ team_key: canonical_team_key,
356
+ agent_id: agent_id.as_str(),
357
+ },
358
+ &state,
359
+ )
360
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
361
+ }
362
+
363
+ fn yaml_value_to_json(value: &Value) -> serde_json::Value {
364
+ match value {
365
+ Value::Null => serde_json::Value::Null,
366
+ Value::Bool(value) => serde_json::json!(value),
367
+ Value::Int(value) => serde_json::json!(value),
368
+ Value::Float(value) => serde_json::Number::from_f64(*value)
369
+ .map(serde_json::Value::Number)
370
+ .unwrap_or(serde_json::Value::Null),
371
+ Value::Str(value) => serde_json::json!(value),
372
+ Value::List(values) => {
373
+ serde_json::Value::Array(values.iter().map(yaml_value_to_json).collect())
374
+ }
375
+ Value::Map(values) => serde_json::Value::Object(
376
+ values
377
+ .iter()
378
+ .map(|(key, value)| (key.clone(), yaml_value_to_json(value)))
379
+ .collect(),
380
+ ),
381
+ }
382
+ }
383
+
384
+ fn starting_agent_entry(
385
+ workspace: &Path,
386
+ agent_id: &AgentId,
387
+ meta: &Value,
388
+ dynamic_role_file: &Path,
389
+ ) -> serde_json::Value {
180
390
  let provider = meta
181
391
  .get("provider")
182
392
  .and_then(Value::as_str)
@@ -189,10 +399,6 @@ pub(super) fn upsert_agent_state_from_role(
189
399
  .get("role")
190
400
  .and_then(Value::as_str)
191
401
  .unwrap_or_else(|| agent_id.as_str());
192
- // E42 (0.3.24 P0, double-spec deadlock): persist the initial state row as
193
- // "starting" (not "running"). The caller (add_agent_with_transport_at_paths)
194
- // promotes to "running" only after start_agent_at_paths returns Running.
195
- // If the spawn fails, the rollback below removes the entry entirely.
196
402
  let mut entry = serde_json::json!({
197
403
  "provider": provider,
198
404
  "auth_mode": auth_mode,
@@ -221,9 +427,6 @@ pub(super) fn upsert_agent_state_from_role(
221
427
  }
222
428
  }
223
429
  }
224
- // 0.4.x provider effort MVP step 8 (dynamic add-agent): persist effort
225
- // from the role doc front matter (compiler.rs validates syntax/semantics
226
- // at compile; add-agent path validates here too in case of direct YAML).
227
430
  if let Some(effort_str) = meta.get("effort").and_then(Value::as_str) {
228
431
  if !effort_str.is_empty() {
229
432
  if let Some(obj) = entry.as_object_mut() {
@@ -232,7 +435,6 @@ pub(super) fn upsert_agent_state_from_role(
232
435
  }
233
436
  }
234
437
  if let Some(obj) = entry.as_object_mut() {
235
- // 0.5.66 bypass 单源:policy 从 role 文档字段派生,不再用全队 `DangerousApproval`。
236
438
  let meta_provider = meta
237
439
  .get("provider")
238
440
  .and_then(Value::as_str)
@@ -240,14 +442,7 @@ pub(super) fn upsert_agent_state_from_role(
240
442
  .unwrap_or(Provider::Codex);
241
443
  persist_effective_approval_policy_from_yaml_agent(obj, meta, meta_provider);
242
444
  }
243
- agent_map.insert(agent_id.as_str().to_string(), entry);
244
- crate::lifecycle::restart::remove::clear_agent_retirement_in_state(&mut state, agent_id);
245
- save_launched_team_state_for_key(
246
- workspace,
247
- &state,
248
- Some(canonical_team_key),
249
- Some(agent_id.as_str()),
250
- )
445
+ entry
251
446
  }
252
447
 
253
448
  /// ---
@@ -59,6 +59,7 @@ pub fn clone_agent(
59
59
  source_agent_id,
60
60
  as_agent_id,
61
61
  label,
62
+ None,
62
63
  )?;
63
64
  // The materialized role carries the source role's DECLARED tools verbatim —
64
65
  // a clone must preserve the source seat's full tools set (add-agent does
@@ -1,9 +1,9 @@
1
1
  //! ---
2
- //! purpose: 在当前席位窗口注入官方斜杠命令完成就地分身,不读 provider session 落盘
2
+ //! purpose: Pi 以完整会话快照创建独立席位;已验证的其他 provider 保持窗口内分身
3
3
  //! contract:
4
4
  //! provides:
5
5
  //! - name: fork_agent_with_transport
6
- //! what: grok/claude subscription 注入纯净斜杠命令;屏幕出现该 provider 的实测标记才算成功
6
+ //! what: Pi 走锁内快照新席位;已验证的其他 provider 保持窗口内斜杠 fork
7
7
  //! - name: in_window_fork
8
8
  //! what: 已验证的 provider+subscription 给出 command+screen_mark;未验证返回 None
9
9
  //! depends:
@@ -12,8 +12,8 @@
12
12
  //! - crate::lifecycle::profile_launch
13
13
  //! - crate::transport::Transport
14
14
  //! boundary:
15
- //! - 不 spawn 新 pane,不改 TEAM_AGENT_ID / MCP / 席位名
16
- //! - 不读 provider 会话落盘文件取会话身份
15
+ //! - 仅 Pi 的锁内专用管道读取 exact backing、注册并启动新席位
16
+ //! - 非 Pi 不 spawn 新 pane,不改 TEAM_AGENT_ID / MCP / 席位名
17
17
  //! - 重试只重按回车,不重粘 /fork
18
18
  //! - 不把 pane 锁超时从 200ms 调大
19
19
  //! - 未验证的 provider 不猜斜杠命令
@@ -99,7 +99,7 @@ pub fn fork_agent_with_transport(
99
99
  workspace: &Path,
100
100
  source_agent_id: &AgentId,
101
101
  as_agent_id: &AgentId,
102
- _label: Option<&str>,
102
+ label: Option<&str>,
103
103
  _open_display: bool,
104
104
  team: Option<&str>,
105
105
  transport: &dyn Transport,
@@ -142,6 +142,15 @@ pub fn fork_agent_with_transport(
142
142
  .unwrap_or("subscription");
143
143
  let provider = parse_provider(provider_raw).unwrap_or(Provider::Grok);
144
144
  let auth = parse_auth_mode(auth_raw).unwrap_or(AuthMode::Subscription);
145
+ if provider == Provider::Pi {
146
+ return crate::lifecycle::restart::fork_pi_new_seat_locked(
147
+ &selected,
148
+ source_agent_id,
149
+ as_agent_id,
150
+ label,
151
+ transport,
152
+ );
153
+ }
145
154
  let Some(spec) = in_window_fork(provider, auth) else {
146
155
  return Err(refuse_missing_in_window_fork(provider, provider_raw));
147
156
  };
@@ -201,6 +210,7 @@ pub fn fork_agent_with_transport(
201
210
  },
202
211
  session_id: None,
203
212
  backing_state: ForkBackingState::Verified,
213
+ pi_fork: None,
204
214
  })
205
215
  }
206
216
 
@@ -44,11 +44,39 @@ pub fn fork_agent(
44
44
  .map_err(|e| LifecycleError::TeamSelect(e.to_string()))?;
45
45
  // Fork-agent routes to the selected live team's persisted endpoint, not
46
46
  // the workspace-hash fallback socket.
47
- let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
48
- &selected.run_workspace,
49
- Some(selected.team_key.as_str()),
50
- )
51
- .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace));
47
+ let is_pi = selected
48
+ .state
49
+ .get("agents")
50
+ .and_then(|agents| agents.get(source_agent_id.as_str()))
51
+ .and_then(|agent| agent.get("provider"))
52
+ .and_then(serde_json::Value::as_str)
53
+ == Some("pi");
54
+ let transport: Box<dyn crate::transport::Transport> = if is_pi {
55
+ #[cfg(test)]
56
+ {
57
+ test_fork_transport(&selected, source_agent_id)
58
+ }
59
+ #[cfg(not(test))]
60
+ {
61
+ Box::new(
62
+ crate::lifecycle::restart::lifecycle_worker_tmux_backend_selection_for_state(
63
+ &selected.run_workspace,
64
+ &selected.state,
65
+ )?
66
+ .backend,
67
+ )
68
+ }
69
+ } else {
70
+ Box::new(
71
+ crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
72
+ &selected.run_workspace,
73
+ Some(selected.team_key.as_str()),
74
+ )
75
+ .unwrap_or_else(|_| {
76
+ crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace)
77
+ }),
78
+ )
79
+ };
52
80
  fork_agent_with_transport(
53
81
  workspace,
54
82
  source_agent_id,
@@ -56,6 +84,61 @@ pub fn fork_agent(
56
84
  label,
57
85
  open_display,
58
86
  team,
59
- &transport,
87
+ transport.as_ref(),
88
+ )
89
+ }
90
+
91
+ #[cfg(test)]
92
+ fn test_fork_transport(
93
+ selected: &crate::state::selector::SelectedTeam,
94
+ source_agent_id: &AgentId,
95
+ ) -> Box<dyn crate::transport::Transport> {
96
+ let state = &selected.state;
97
+ let session = state
98
+ .get("session_name")
99
+ .and_then(serde_json::Value::as_str)
100
+ .filter(|value| !value.is_empty())
101
+ .map(crate::transport::SessionName::new)
102
+ .unwrap_or_else(|| crate::transport::SessionName::new("offline"));
103
+ let source = state
104
+ .get("agents")
105
+ .and_then(|agents| agents.get(source_agent_id.as_str()));
106
+ let targets = source
107
+ .and_then(|agent| agent.get("pane_id"))
108
+ .and_then(serde_json::Value::as_str)
109
+ .filter(|pane| !pane.is_empty())
110
+ .map(|pane| {
111
+ let window = source
112
+ .and_then(|agent| agent.get("window"))
113
+ .and_then(serde_json::Value::as_str)
114
+ .filter(|value| !value.is_empty())
115
+ .unwrap_or(source_agent_id.as_str());
116
+ vec![crate::transport::PaneInfo {
117
+ pane_id: crate::transport::PaneId::new(pane),
118
+ session: session.clone(),
119
+ window_index: None,
120
+ window_name: Some(crate::transport::WindowName::new(window)),
121
+ pane_index: None,
122
+ tty: None,
123
+ current_command: None,
124
+ current_path: None,
125
+ active: false,
126
+ pane_pid: None,
127
+ leader_env: std::collections::BTreeMap::new(),
128
+ }]
129
+ })
130
+ .unwrap_or_default();
131
+ let endpoint = state
132
+ .get("tmux_endpoint")
133
+ .or_else(|| state.get("tmux_socket"))
134
+ .and_then(serde_json::Value::as_str)
135
+ .filter(|value| !value.is_empty())
136
+ .map(str::to_string)
137
+ .unwrap_or_else(|| "offline".to_string());
138
+ Box::new(
139
+ crate::transport::test_support::OfflineTransport::new()
140
+ .with_session_present(true)
141
+ .with_targets(targets)
142
+ .with_tmux_endpoint(endpoint),
60
143
  )
61
144
  }