@team-agent/installer 0.3.21 → 0.3.23

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 (31) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +9 -1
  4. package/crates/team-agent/src/cli/mod.rs +158 -2
  5. package/crates/team-agent/src/cli/status.rs +61 -0
  6. package/crates/team-agent/src/cli/status_port.rs +2 -2
  7. package/crates/team-agent/src/cli/tests/base.rs +26 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +233 -9
  9. package/crates/team-agent/src/coordinator/tests/energy.rs +548 -0
  10. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tick.rs +452 -17
  12. package/crates/team-agent/src/lifecycle/display.rs +23 -302
  13. package/crates/team-agent/src/lifecycle/launch.rs +38 -0
  14. package/crates/team-agent/src/lifecycle/restart/agent.rs +27 -6
  15. package/crates/team-agent/src/lifecycle/restart/common.rs +179 -0
  16. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +295 -13
  17. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +30 -0
  18. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +89 -5
  19. package/crates/team-agent/src/messaging/delivery.rs +324 -95
  20. package/crates/team-agent/src/messaging/scheduler.rs +95 -73
  21. package/crates/team-agent/src/messaging/send.rs +10 -0
  22. package/crates/team-agent/src/messaging/tests/runtime.rs +460 -0
  23. package/crates/team-agent/src/messaging/tests/spine.rs +51 -2
  24. package/crates/team-agent/src/session_capture.rs +261 -1
  25. package/crates/team-agent/src/tmux_backend/tests.rs +97 -1
  26. package/crates/team-agent/src/tmux_backend.rs +138 -2
  27. package/crates/team-agent/src/transport/test_support.rs +25 -0
  28. package/crates/team-agent/src/transport.rs +11 -0
  29. package/package.json +4 -4
  30. package/skills/team-agent/SKILL.md +2 -1
  31. package/skills/team-agent/references/team-in-team.md +127 -0
@@ -187,6 +187,16 @@ pub(super) fn spawn_agent_window(
187
187
  )
188
188
  };
189
189
  let spawn = result.map_err(|e| LifecycleError::Transport(e.to_string()))?;
190
+ if layout_placement.is_some() {
191
+ crate::lifecycle::launch::configure_adaptive_pane_title(
192
+ workspace,
193
+ transport,
194
+ session_name,
195
+ &window,
196
+ &spawn.pane_id,
197
+ agent_id.as_str(),
198
+ );
199
+ }
190
200
  let _ = adapter.handle_startup_prompts(
191
201
  transport,
192
202
  &crate::transport::Target::Pane(spawn.pane_id.clone()),
@@ -247,6 +257,72 @@ pub(super) fn persist_effective_approval_policy_for_restart(
247
257
  crate::lifecycle::launch::persist_effective_approval_policy(agent, safety);
248
258
  }
249
259
 
260
+ pub(super) fn save_restart_projected_state(
261
+ workspace: &Path,
262
+ state: &mut serde_json::Value,
263
+ team_key: &str,
264
+ ) -> Result<(), LifecycleError> {
265
+ sync_restart_team_projections(state, team_key);
266
+ crate::state::projection::save_team_scoped_state(workspace, state)
267
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))
268
+ }
269
+
270
+ pub(super) fn restart_projection_team_key(
271
+ state: &serde_json::Value,
272
+ team: Option<&str>,
273
+ ) -> String {
274
+ team.filter(|key| !key.is_empty())
275
+ .map(str::to_string)
276
+ .or_else(|| {
277
+ state
278
+ .get("active_team_key")
279
+ .and_then(serde_json::Value::as_str)
280
+ .filter(|key| !key.is_empty())
281
+ .map(str::to_string)
282
+ })
283
+ .unwrap_or_else(|| crate::state::projection::team_state_key(state))
284
+ }
285
+
286
+ pub(super) fn sync_restart_team_projections(state: &mut serde_json::Value, team_key: &str) {
287
+ let Some(teams) = state.get("teams").and_then(serde_json::Value::as_object) else {
288
+ return;
289
+ };
290
+ if teams.is_empty() {
291
+ return;
292
+ }
293
+ let compact = crate::state::projection::compact_team_state(state);
294
+ let active_key = state
295
+ .get("active_team_key")
296
+ .and_then(serde_json::Value::as_str)
297
+ .filter(|key| !key.is_empty())
298
+ .map(str::to_string);
299
+ let derived_key = crate::state::projection::team_state_key(state);
300
+ let Some(teams) = state
301
+ .get_mut("teams")
302
+ .and_then(serde_json::Value::as_object_mut)
303
+ else {
304
+ return;
305
+ };
306
+ let mut keys = Vec::new();
307
+ if !team_key.is_empty() {
308
+ keys.push(team_key.to_string());
309
+ }
310
+ if let Some(active_key) = active_key {
311
+ keys.push(active_key);
312
+ }
313
+ if !derived_key.is_empty() {
314
+ keys.push(derived_key);
315
+ }
316
+ if teams.contains_key("current") {
317
+ keys.push("current".to_string());
318
+ }
319
+ keys.sort();
320
+ keys.dedup();
321
+ for key in keys {
322
+ teams.insert(key, compact.clone());
323
+ }
324
+ }
325
+
250
326
  pub(super) fn state_session_name(state: &serde_json::Value) -> SessionName {
251
327
  state
252
328
  .get("session_name")
@@ -327,6 +403,7 @@ pub(super) fn resume_backing_exists_for_agent(
327
403
  Provider::Claude | Provider::ClaudeCode => {
328
404
  rollout_path_exists(rollout_path)
329
405
  || event_log_transcript_exists(workspace, agent_id.as_str(), session_id.as_str())
406
+ || claude_project_transcript_exists(agent, session_id.as_str())
330
407
  }
331
408
  Provider::Copilot => copilot_session_store_has_session(session_id.as_str()),
332
409
  Provider::GeminiCli | Provider::Fake => false,
@@ -372,6 +449,42 @@ fn event_transcript_path(event: &serde_json::Value) -> Option<PathBuf> {
372
449
  .map(PathBuf::from)
373
450
  }
374
451
 
452
+ /// E36 fix-B: a real Claude worker writes its session transcript to
453
+ /// `<claude_projects_root>/<workspace-slug>/<session_id>.jsonl` even when neither
454
+ /// `rollout_path` was persisted to state nor a `session.captured` event was logged.
455
+ /// That landed transcript is itself a valid resume backing — restart was wrongly
456
+ /// refusing resumable workers because it only checked the two paths above. Scan the
457
+ /// projects root recursively for `<session_id>.jsonl` (session_id is a unique UUID,
458
+ /// so we avoid recomputing the project-dir slug, which is brittle for non-ASCII
459
+ /// workspace paths).
460
+ fn claude_project_transcript_exists(agent: &serde_json::Value, session_id: &str) -> bool {
461
+ if session_id.is_empty() {
462
+ return false;
463
+ }
464
+ let projects_root = agent
465
+ .get("claude_projects_root")
466
+ .and_then(serde_json::Value::as_str)
467
+ .filter(|value| !value.is_empty())
468
+ .map(PathBuf::from)
469
+ .or_else(|| {
470
+ std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude").join("projects"))
471
+ });
472
+ let Some(projects_root) = projects_root else {
473
+ return false;
474
+ };
475
+ if !projects_root.is_dir() {
476
+ return false;
477
+ }
478
+ let transcript_name = format!("{session_id}.jsonl");
479
+ let Ok(project_dirs) = std::fs::read_dir(&projects_root) else {
480
+ return false;
481
+ };
482
+ project_dirs
483
+ .flatten()
484
+ .filter(|entry| entry.path().is_dir())
485
+ .any(|entry| entry.path().join(&transcript_name).is_file())
486
+ }
487
+
375
488
  fn copilot_session_store_has_session(session_id: &str) -> bool {
376
489
  let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
377
490
  return false;
@@ -832,3 +945,69 @@ pub(super) fn is_dynamic_agent(
832
945
  .and_then(YamlValue::as_str)
833
946
  .is_some_and(|s| !s.is_empty())
834
947
  }
948
+
949
+ #[cfg(test)]
950
+ mod e36_transcript_backing_tests {
951
+ use super::*;
952
+
953
+ struct ScratchDir(PathBuf);
954
+ impl ScratchDir {
955
+ fn new(tag: &str) -> Self {
956
+ let pid = std::process::id();
957
+ let base = std::env::temp_dir().join(format!("ta-e36-{tag}-{pid}"));
958
+ std::fs::create_dir_all(&base).expect("scratch dir");
959
+ ScratchDir(base)
960
+ }
961
+ fn path(&self) -> &Path {
962
+ &self.0
963
+ }
964
+ }
965
+ impl Drop for ScratchDir {
966
+ fn drop(&mut self) {
967
+ let _ = std::fs::remove_dir_all(&self.0);
968
+ }
969
+ }
970
+
971
+ // E36 fix-B RED→GREEN: a real Claude worker that sent a message has its session
972
+ // transcript landed at <projects_root>/<slug>/<session_id>.jsonl, but neither
973
+ // rollout_path was persisted to state nor a session.captured event was logged.
974
+ // Before the fix, claude_project_transcript_exists did not exist and restart
975
+ // refused such a worker. This asserts the landed transcript is recognized.
976
+ #[test]
977
+ fn claude_project_transcript_is_recognized_without_rollout_or_capture_event() {
978
+ let scratch = ScratchDir::new("recognized");
979
+ let projects_root = scratch.path().join("projects");
980
+ let slug_dir = projects_root.join("-Users-alauda-Documents-code---rust---9");
981
+ std::fs::create_dir_all(&slug_dir).expect("mkdir slug");
982
+ let session_id = "87742d3f-0b4e-4fc1-ad35-447ac2340b65";
983
+ std::fs::write(slug_dir.join(format!("{session_id}.jsonl")), b"{}\n").expect("transcript");
984
+
985
+ let agent = serde_json::json!({
986
+ "claude_projects_root": projects_root.to_string_lossy(),
987
+ });
988
+ assert!(
989
+ claude_project_transcript_exists(&agent, session_id),
990
+ "landed claude transcript must count as resume backing (E36 fix-B)"
991
+ );
992
+ }
993
+
994
+ #[test]
995
+ fn missing_claude_transcript_is_not_backing() {
996
+ let scratch = ScratchDir::new("missing");
997
+ let projects_root = scratch.path().join("projects");
998
+ std::fs::create_dir_all(&projects_root).expect("mkdir");
999
+ let agent = serde_json::json!({
1000
+ "claude_projects_root": projects_root.to_string_lossy(),
1001
+ });
1002
+ assert!(
1003
+ !claude_project_transcript_exists(&agent, "deadbeef-0000-0000-0000-000000000000"),
1004
+ "no transcript file => no backing"
1005
+ );
1006
+ }
1007
+
1008
+ #[test]
1009
+ fn empty_session_id_is_not_backing() {
1010
+ let agent = serde_json::json!({});
1011
+ assert!(!claude_project_transcript_exists(&agent, ""));
1012
+ }
1013
+ }
@@ -23,8 +23,7 @@ pub fn restart_with_session_convergence_deadline(
23
23
  session_converge_deadline_ms: Option<u64>,
24
24
  ) -> Result<RestartReport, LifecycleError> {
25
25
  let paths = lifecycle_paths(workspace, team)?;
26
- let transport =
27
- lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
26
+ let transport = lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
28
27
  restart_with_transport_with_session_convergence_deadline(
29
28
  workspace,
30
29
  allow_fresh,
@@ -148,12 +147,10 @@ pub fn restart_with_transport_with_session_convergence_deadline(
148
147
  allow_fresh,
149
148
  )?;
150
149
  if convergence.converged && convergence.changed {
151
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
152
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
150
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
153
151
  }
154
152
  if repair_resume_sessions_from_event_log(&selected.run_workspace, &mut state)? {
155
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
156
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
153
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
157
154
  let missing_after_repair = restart_required_missing_session_agent_ids(&state);
158
155
  convergence.changed = true;
159
156
  convergence.converged = missing_after_repair.is_empty();
@@ -173,8 +170,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
173
170
  });
174
171
  }
175
172
  if !convergence.converged && convergence.changed {
176
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
177
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
173
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
178
174
  }
179
175
  let mut forced_fresh_missing = if convergence.converged {
180
176
  std::collections::BTreeSet::new()
@@ -222,8 +218,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
222
218
  .map_err(|e| LifecycleError::Transport(e.to_string()))?;
223
219
  mark_leader_receiver_rebind_required(&mut state, &session_name);
224
220
  mark_restart_targets_stopped_after_teardown(&mut state, &plan.decisions);
225
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
226
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
221
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
227
222
  }
228
223
  let mut successful_agents: Vec<RestartedAgent> = Vec::new();
229
224
  let mut failed_agents: Vec<RestartFailedAgent> = Vec::new();
@@ -353,8 +348,7 @@ pub fn restart_with_transport_with_session_convergence_deadline(
353
348
  }
354
349
  }
355
350
  // END_B5_RESTART_ISOLATION_LOOP
356
- crate::state::projection::save_team_scoped_state(&selected.run_workspace, &state)
357
- .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
351
+ save_restart_state(&selected.run_workspace, &mut state, &selected.team_key)?;
358
352
  if fatal_resume_failure {
359
353
  let attach_commands = Vec::new();
360
354
  let next_actions = restart_failure_next_actions(&failed_agents);
@@ -868,6 +862,14 @@ fn mark_restart_targets_stopped_after_teardown(
868
862
  }
869
863
  }
870
864
 
865
+ fn save_restart_state(
866
+ workspace: &Path,
867
+ state: &mut serde_json::Value,
868
+ team_key: &str,
869
+ ) -> Result<(), LifecycleError> {
870
+ save_restart_projected_state(workspace, state, team_key)
871
+ }
872
+
871
873
  fn mark_agent_respawned(
872
874
  state: &mut serde_json::Value,
873
875
  agent_id: &AgentId,
@@ -906,7 +908,13 @@ fn mark_agent_respawned(
906
908
  });
907
909
  if let Some(pane_pid) = pane_pid {
908
910
  agent.insert("pane_pid".to_string(), serde_json::json!(pane_pid));
911
+ } else {
912
+ agent.remove("pane_pid");
909
913
  }
914
+ agent.insert(
915
+ "spawned_at".to_string(),
916
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
917
+ );
910
918
  if matches!(
911
919
  restart_mode,
912
920
  StartMode::Fresh | StartMode::FreshAfterMissingRollout
@@ -929,7 +937,10 @@ fn mark_agent_respawned(
929
937
  "layout_index".to_string(),
930
938
  serde_json::json!(placement.layout_index),
931
939
  );
932
- agent.insert("pane_index".to_string(), serde_json::json!(placement.pane_index));
940
+ agent.insert(
941
+ "pane_index".to_string(),
942
+ serde_json::json!(placement.pane_index),
943
+ );
933
944
  agent.insert(
934
945
  "display".to_string(),
935
946
  serde_json::json!({
@@ -963,6 +974,8 @@ fn mark_agent_respawned(
963
974
  }
964
975
  agent.remove("startup_prompts");
965
976
  agent.remove("startup_prompt_status");
977
+ agent.remove("startup_prompt_probe_epoch");
978
+ agent.remove("startup_prompt_probe_disabled_at");
966
979
  Ok(())
967
980
  }
968
981
 
@@ -1403,3 +1416,272 @@ fn restart_candidate_has_context(state: &serde_json::Value) -> bool {
1403
1416
  })
1404
1417
  })
1405
1418
  }
1419
+
1420
+ #[cfg(test)]
1421
+ mod tests {
1422
+ use super::*;
1423
+ use std::collections::BTreeMap;
1424
+ use std::path::Path;
1425
+
1426
+ use crate::transport::{
1427
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport, Key,
1428
+ PaneField, PaneInfo, PaneLiveness, SetEnvOutcome, SpawnResult, Target, Transport,
1429
+ TransportError,
1430
+ };
1431
+
1432
+ struct RespawnEpochTransport;
1433
+
1434
+ impl Transport for RespawnEpochTransport {
1435
+ fn kind(&self) -> BackendKind {
1436
+ BackendKind::Tmux
1437
+ }
1438
+
1439
+ fn spawn_first(
1440
+ &self,
1441
+ _session: &SessionName,
1442
+ _window: &WindowName,
1443
+ _argv: &[String],
1444
+ _cwd: &Path,
1445
+ _env: &BTreeMap<String, String>,
1446
+ ) -> Result<SpawnResult, TransportError> {
1447
+ unimplemented!("not used by respawn epoch test")
1448
+ }
1449
+
1450
+ fn spawn_into(
1451
+ &self,
1452
+ _session: &SessionName,
1453
+ _window: &WindowName,
1454
+ _argv: &[String],
1455
+ _cwd: &Path,
1456
+ _env: &BTreeMap<String, String>,
1457
+ ) -> Result<SpawnResult, TransportError> {
1458
+ unimplemented!("not used by respawn epoch test")
1459
+ }
1460
+
1461
+ fn inject(
1462
+ &self,
1463
+ _target: &Target,
1464
+ _payload: &InjectPayload,
1465
+ _submit: Key,
1466
+ _bracketed: bool,
1467
+ ) -> Result<InjectReport, TransportError> {
1468
+ unimplemented!("not used by respawn epoch test")
1469
+ }
1470
+
1471
+ fn send_keys(&self, _target: &Target, _keys: &[Key]) -> Result<(), TransportError> {
1472
+ unimplemented!("not used by respawn epoch test")
1473
+ }
1474
+
1475
+ fn capture(
1476
+ &self,
1477
+ _target: &Target,
1478
+ range: CaptureRange,
1479
+ ) -> Result<CapturedText, TransportError> {
1480
+ Ok(CapturedText {
1481
+ text: String::new(),
1482
+ range,
1483
+ })
1484
+ }
1485
+
1486
+ fn query(
1487
+ &self,
1488
+ _target: &Target,
1489
+ _field: PaneField,
1490
+ ) -> Result<Option<String>, TransportError> {
1491
+ Ok(None)
1492
+ }
1493
+
1494
+ fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
1495
+ Ok(PaneLiveness::Live)
1496
+ }
1497
+
1498
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
1499
+ Ok(Vec::new())
1500
+ }
1501
+
1502
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
1503
+ Ok(true)
1504
+ }
1505
+
1506
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
1507
+ Ok(Vec::new())
1508
+ }
1509
+
1510
+ fn set_session_env(
1511
+ &self,
1512
+ _session: &SessionName,
1513
+ _key: &str,
1514
+ _value: &str,
1515
+ ) -> Result<SetEnvOutcome, TransportError> {
1516
+ Ok(SetEnvOutcome::Applied)
1517
+ }
1518
+
1519
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
1520
+ Ok(())
1521
+ }
1522
+
1523
+ fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
1524
+ Ok(())
1525
+ }
1526
+
1527
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
1528
+ Ok(AttachOutcome::Attached)
1529
+ }
1530
+ }
1531
+
1532
+ fn disabled_safety() -> DangerousApproval {
1533
+ DangerousApproval {
1534
+ enabled: false,
1535
+ source: DangerousApprovalSource::Disabled,
1536
+ inherited: false,
1537
+ provider: None,
1538
+ flag: None,
1539
+ worker_capability_above_leader: false,
1540
+ ancestry_binary_name: None,
1541
+ unexpected_binary: false,
1542
+ }
1543
+ }
1544
+
1545
+ #[test]
1546
+ fn respawn_refreshes_startup_probe_epoch_inputs() {
1547
+ let mut state = serde_json::json!({
1548
+ "agents": {
1549
+ "w1": {
1550
+ "status": "running",
1551
+ "window": "w1-old",
1552
+ "pane_id": "%old",
1553
+ "pane_pid": 1010,
1554
+ "spawned_at": "2026-06-01T00:00:00+00:00",
1555
+ "startup_prompts": "disabled_for_epoch",
1556
+ "startup_prompt_status": "disabled_for_epoch",
1557
+ "startup_prompt_probe_epoch": "pane_pid:1010",
1558
+ "startup_prompt_probe_disabled_at": "2026-06-01T00:02:30+00:00"
1559
+ }
1560
+ }
1561
+ });
1562
+ let spawn = SpawnedAgentWindow {
1563
+ spawn: SpawnResult {
1564
+ pane_id: PaneId::new("%new"),
1565
+ session: SessionName::new("team-epoch"),
1566
+ window: WindowName::new("w1"),
1567
+ child_pid: Some(2020),
1568
+ },
1569
+ plan: crate::provider::CommandPlan::argv_only(vec!["codex".to_string()]),
1570
+ profile_launch: crate::provider::ProviderProfileLaunch::default(),
1571
+ layout_placement: None,
1572
+ };
1573
+ let before = chrono::Utc::now();
1574
+
1575
+ mark_agent_respawned(
1576
+ &mut state,
1577
+ &AgentId::new("w1"),
1578
+ StartMode::Resumed,
1579
+ &spawn,
1580
+ &RespawnEpochTransport,
1581
+ &disabled_safety(),
1582
+ )
1583
+ .expect("mark respawned");
1584
+
1585
+ let agent = state.pointer("/agents/w1").expect("agent");
1586
+ assert_eq!(
1587
+ agent.get("pane_id").and_then(serde_json::Value::as_str),
1588
+ Some("%new")
1589
+ );
1590
+ assert_eq!(
1591
+ agent.get("pane_pid").and_then(serde_json::Value::as_u64),
1592
+ Some(2020)
1593
+ );
1594
+ let spawned_at = agent
1595
+ .get("spawned_at")
1596
+ .and_then(serde_json::Value::as_str)
1597
+ .expect("spawned_at refreshed");
1598
+ let spawned_at = chrono::DateTime::parse_from_rfc3339(spawned_at)
1599
+ .expect("spawned_at rfc3339")
1600
+ .with_timezone(&chrono::Utc);
1601
+ assert!(
1602
+ spawned_at >= before - chrono::Duration::seconds(1),
1603
+ "respawn must start a fresh startup-probe grace window; spawned_at={spawned_at}, before={before}"
1604
+ );
1605
+ for key in [
1606
+ "startup_prompts",
1607
+ "startup_prompt_status",
1608
+ "startup_prompt_probe_epoch",
1609
+ "startup_prompt_probe_disabled_at",
1610
+ ] {
1611
+ assert!(
1612
+ agent.get(key).is_none(),
1613
+ "respawned pane must not inherit old startup prompt epoch field {key}"
1614
+ );
1615
+ }
1616
+ }
1617
+
1618
+ #[test]
1619
+ fn restart_projection_sync_updates_active_team_and_current_alias() {
1620
+ let mut state = serde_json::json!({
1621
+ "active_team_key": "team-alpha",
1622
+ "team_dir": "/tmp/team-alpha",
1623
+ "session_name": "team-alpha",
1624
+ "agents": {
1625
+ "alpha": {
1626
+ "status": "running",
1627
+ "pane_id": "%new",
1628
+ "pane_pid": 47650,
1629
+ "spawned_at": "2026-06-13T04:00:00+00:00"
1630
+ }
1631
+ },
1632
+ "teams": {
1633
+ "current": {
1634
+ "team_dir": "/tmp/team-alpha",
1635
+ "session_name": "team-alpha",
1636
+ "agents": {
1637
+ "alpha": {
1638
+ "status": "running",
1639
+ "pane_id": "%new",
1640
+ "pane_pid": 47650,
1641
+ "spawned_at": "2026-06-13T04:00:00+00:00"
1642
+ }
1643
+ }
1644
+ },
1645
+ "team-alpha": {
1646
+ "team_dir": "/tmp/team-alpha",
1647
+ "session_name": "team-alpha",
1648
+ "agents": {
1649
+ "alpha": {
1650
+ "status": "running",
1651
+ "pane_id": "%old",
1652
+ "pane_pid": 46784,
1653
+ "spawned_at": "2026-06-13T03:00:00+00:00",
1654
+ "startup_prompt_probe_epoch": "pane_pid:46784"
1655
+ }
1656
+ }
1657
+ }
1658
+ }
1659
+ });
1660
+
1661
+ sync_restart_team_projections(&mut state, "team-alpha");
1662
+
1663
+ for pointer in [
1664
+ "/agents/alpha",
1665
+ "/teams/current/agents/alpha",
1666
+ "/teams/team-alpha/agents/alpha",
1667
+ ] {
1668
+ let agent = state.pointer(pointer).expect(pointer);
1669
+ assert_eq!(
1670
+ agent.get("pane_id").and_then(serde_json::Value::as_str),
1671
+ Some("%new")
1672
+ );
1673
+ assert_eq!(
1674
+ agent.get("pane_pid").and_then(serde_json::Value::as_u64),
1675
+ Some(47650)
1676
+ );
1677
+ assert_eq!(
1678
+ agent.get("spawned_at").and_then(serde_json::Value::as_str),
1679
+ Some("2026-06-13T04:00:00+00:00")
1680
+ );
1681
+ assert!(
1682
+ agent.get("startup_prompt_probe_epoch").is_none(),
1683
+ "{pointer} must not retain the old startup probe epoch"
1684
+ );
1685
+ }
1686
+ }
1687
+ }
@@ -1118,6 +1118,20 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1118
1118
  vec!["spawn_first", "spawn_split", "spawn_split", "spawn_into"],
1119
1119
  "4 default-adaptive workers should create team-w1 with 3 panes, then team-w2"
1120
1120
  );
1121
+ assert_eq!(
1122
+ transport
1123
+ .pane_title_records()
1124
+ .iter()
1125
+ .map(|(_, window, pane, title)| (window.as_str(), pane.as_str(), title.as_str()))
1126
+ .collect::<Vec<_>>(),
1127
+ vec![
1128
+ ("team-w1", "%0", "w1"),
1129
+ ("team-w1", "%1", "w2"),
1130
+ ("team-w1", "%2", "w3"),
1131
+ ("team-w2", "%3", "w4"),
1132
+ ],
1133
+ "adaptive workers must set tmux pane titles to the agent id"
1134
+ );
1121
1135
  assert_eq!(
1122
1136
  launch
1123
1137
  .started
@@ -1147,6 +1161,14 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1147
1161
  );
1148
1162
 
1149
1163
  let (_raw, state) = raw_runtime_state(workspace);
1164
+ assert!(
1165
+ transport
1166
+ .pane_title_records()
1167
+ .iter()
1168
+ .all(|(session, _, _, _)| Some(session.as_str())
1169
+ == state.get("session_name").and_then(serde_json::Value::as_str)),
1170
+ "pane titles must be configured inside the team session"
1171
+ );
1150
1172
  assert_eq!(state["display_backend"], json!("adaptive"));
1151
1173
  assert_eq!(state.pointer("/agents/w1/window"), Some(&json!("team-w1")));
1152
1174
  assert_eq!(state.pointer("/agents/w1/layout_window"), Some(&json!("team-w1")));
@@ -1155,6 +1177,14 @@ fn quick_start_default_adaptive_groups_workers_into_layout_panes() {
1155
1177
  state.pointer("/agents/w1/display/backend"),
1156
1178
  Some(&json!("adaptive"))
1157
1179
  );
1180
+ assert_eq!(
1181
+ state.pointer("/agents/w1/display/pane_title"),
1182
+ Some(&json!("w1"))
1183
+ );
1184
+ assert_eq!(
1185
+ state.pointer("/agents/w1/display/linked_session"),
1186
+ Some(&json!(null))
1187
+ );
1158
1188
  assert_eq!(
1159
1189
  state.pointer("/agents/w4/layout_window"),
1160
1190
  Some(&json!("team-w2"))