@team-agent/installer 0.1.4 → 0.1.9

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.
@@ -35,7 +35,15 @@ from team_agent.providers import ResumeUnavailable, get_adapter, shell_command_f
35
35
  from team_agent.routing import route_task
36
36
  from team_agent.simple_yaml import dumps
37
37
  from team_agent.spec import load_spec, validate_result_envelope, workspace_from_spec
38
- from team_agent.state import load_runtime_state, runtime_state_path, save_runtime_state, write_team_state
38
+ from team_agent.state import (
39
+ SESSION_CAPTURE_FIELDS,
40
+ SESSION_STATE_FIELDS,
41
+ load_runtime_state,
42
+ normalize_agent_session_state,
43
+ runtime_state_path,
44
+ save_runtime_state,
45
+ write_team_state,
46
+ )
39
47
  from team_agent.task_graph import ready_tasks, update_task_status
40
48
  from team_agent.task_graph import TASK_STATUSES
41
49
 
@@ -46,6 +54,7 @@ TMUX_PANE_FORMAT = (
46
54
  "#{pane_current_path}\t#{session_attached}"
47
55
  )
48
56
  HEALTH_STATUSES = {"RUNNING", "IDLE", "AWAITING_APPROVAL", "BLOCKED", "ERROR", "DONE"}
57
+ GHOSTTY_DISPLAY_BACKENDS = {"ghostty", "ghostty_window", "ghostty_workspace"}
49
58
  PEEK_MAX_LINES = 80
50
59
  PEEK_SEARCH_SCAN_LINES = 300
51
60
  PEEK_MAX_MATCHES = 5
@@ -448,10 +457,16 @@ def launch(
448
457
  timeout_s=1.5,
449
458
  exclude_session_ids=known_session_ids,
450
459
  )
451
- if state.get("display_backend") in {"ghostty", "ghostty_window"}:
460
+ if state.get("display_backend") in GHOSTTY_DISPLAY_BACKENDS:
452
461
  display_jobs.append((agent["id"], agent))
453
462
  started.append({"agent_id": agent["id"], "provider": agent["provider"], "window": agent["id"]})
454
- for agent_id, display in _open_worker_displays(workspace, session_name, display_jobs, event_log).items():
463
+ for agent_id, display in _open_worker_displays(
464
+ workspace,
465
+ session_name,
466
+ display_jobs,
467
+ event_log,
468
+ state.get("display_backend", "none"),
469
+ ).items():
455
470
  if agent_id in state["agents"]:
456
471
  state["agents"][agent_id]["display"] = display
457
472
  save_runtime_state(workspace, state)
@@ -520,10 +535,7 @@ def _load_snapshot_state(path: Path) -> dict[str, Any] | None:
520
535
  state = json.loads(path.read_text(encoding="utf-8"))
521
536
  except (OSError, json.JSONDecodeError):
522
537
  return None
523
- for agent_state in state.get("agents", {}).values():
524
- if isinstance(agent_state, dict):
525
- for field in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence", "spawn_cwd"]:
526
- agent_state.setdefault(field, None)
538
+ normalize_agent_session_state(state)
527
539
  return state
528
540
 
529
541
 
@@ -1570,8 +1582,7 @@ def _capture_agent_session(
1570
1582
  result = adapter.capture_session_id(agent_id, spawn_context, timeout_s=timeout_s)
1571
1583
  if not isinstance(result, dict) or not result.get("session_id"):
1572
1584
  return None
1573
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence", "spawn_cwd"]:
1574
- agent_state[key] = result.get(key)
1585
+ _copy_session_metadata(agent_state, result)
1575
1586
  agent_state.pop("_pending_session_id", None)
1576
1587
  event_log.write(
1577
1588
  "session.captured",
@@ -1585,6 +1596,16 @@ def _capture_agent_session(
1585
1596
  return result
1586
1597
 
1587
1598
 
1599
+ def _copy_session_metadata(target: dict[str, Any], source: dict[str, Any]) -> None:
1600
+ for key in SESSION_STATE_FIELDS:
1601
+ target[key] = source.get(key)
1602
+
1603
+
1604
+ def _clear_session_capture_fields(target: dict[str, Any]) -> None:
1605
+ for key in SESSION_CAPTURE_FIELDS:
1606
+ target[key] = None
1607
+
1608
+
1588
1609
  def _attach_profile_resume_root(workspace: Path, command_agent: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
1589
1610
  profile_launch = command_agent.get("_provider_profile") or prepare_agent_profile_launch(workspace, command_agent)
1590
1611
  if not profile_launch:
@@ -1631,8 +1652,7 @@ def _prepare_resume_state(
1631
1652
  if not repaired:
1632
1653
  repaired = adapter.recover_session_id(agent_id, prepared, workspace, exclude_session_ids or set())
1633
1654
  if repaired:
1634
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence", "spawn_cwd"]:
1635
- prepared[key] = repaired.get(key)
1655
+ _copy_session_metadata(prepared, repaired)
1636
1656
  event_log.write(
1637
1657
  "resume.session_repaired",
1638
1658
  agent_id=agent_id,
@@ -1657,8 +1677,7 @@ def _prepare_resume_state(
1657
1677
  f"Cannot resume agent {agent_id}: stored session {session_id} is not available. "
1658
1678
  "Use --allow-fresh only if losing that worker context is acceptable."
1659
1679
  )
1660
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence"]:
1661
- prepared[key] = None
1680
+ _clear_session_capture_fields(prepared)
1662
1681
  event_log.write(
1663
1682
  "resume.session_unavailable",
1664
1683
  agent_id=agent_id,
@@ -1741,6 +1760,7 @@ def shutdown(workspace: Path, keep_logs: bool = True) -> dict[str, Any]:
1741
1760
  if proc.returncode == 0:
1742
1761
  log_path.write_text(proc.stdout, encoding="utf-8")
1743
1762
  captured.append(str(log_path))
1763
+ _close_ghostty_workspace(state, event_log)
1744
1764
  for agent_id, agent_state in state.get("agents", {}).items():
1745
1765
  _close_ghostty_display(agent_id, agent_state, event_log)
1746
1766
  closed_displays.add(agent_id)
@@ -1750,6 +1770,7 @@ def shutdown(workspace: Path, keep_logs: bool = True) -> dict[str, Any]:
1750
1770
  event_log.write("shutdown.kill_session", session=session_name, keep_logs=keep_logs, captured=captured)
1751
1771
  else:
1752
1772
  event_log.write("shutdown.idempotent", session=session_name, reason="session missing")
1773
+ _close_ghostty_workspace(state, event_log)
1753
1774
  for agent_id, agent_state in state.get("agents", {}).items():
1754
1775
  if agent_id not in closed_displays:
1755
1776
  _close_ghostty_display(agent_id, agent_state, event_log)
@@ -1972,8 +1993,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
1972
1993
  if profile_launch.get("claude_projects_root"):
1973
1994
  agent_state["claude_projects_root"] = profile_launch["claude_projects_root"]
1974
1995
  if restart_mode == "fresh":
1975
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence"]:
1976
- agent_state[key] = None
1996
+ _clear_session_capture_fields(agent_state)
1977
1997
  if command_agent.get("_session_id"):
1978
1998
  agent_state["_pending_session_id"] = command_agent["_session_id"]
1979
1999
  _capture_agent_session(
@@ -1984,7 +2004,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
1984
2004
  timeout_s=1.5,
1985
2005
  exclude_session_ids=known_session_ids,
1986
2006
  )
1987
- if display_backend in {"ghostty", "ghostty_window"}:
2007
+ if display_backend in GHOSTTY_DISPLAY_BACKENDS:
1988
2008
  display_jobs.append((agent["id"], agent))
1989
2009
  new_agents[agent["id"]] = agent_state
1990
2010
  restarted.append(
@@ -1995,7 +2015,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
1995
2015
  "display_target": None,
1996
2016
  }
1997
2017
  )
1998
- display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log)
2018
+ display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log, display_backend)
1999
2019
  for agent_id, display in display_results.items():
2000
2020
  if agent_id in new_agents:
2001
2021
  new_agents[agent_id]["display"] = display
@@ -2061,6 +2081,10 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
2061
2081
  display = agent_state.get("display") or {}
2062
2082
  if display.get("status") != "opened":
2063
2083
  agent_state["display"] = _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)
2084
+ elif open_display and state.get("display_backend") == "ghostty_workspace":
2085
+ display = agent_state.get("display") or {}
2086
+ if display.get("status") != "opened":
2087
+ agent_state["display"] = _open_ghostty_workspace_agent_display(session_name, agent_id, agent, display, event_log)
2064
2088
  state["agents"][agent_id] = agent_state
2065
2089
  save_runtime_state(workspace, state)
2066
2090
  write_team_state(workspace, spec, state)
@@ -2231,13 +2255,20 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
2231
2255
  if profile_launch.get("claude_projects_root"):
2232
2256
  agent_state["claude_projects_root"] = profile_launch["claude_projects_root"]
2233
2257
  if start_mode == "fresh":
2234
- for key in ["session_id", "rollout_path", "captured_at", "captured_via", "attribution_confidence"]:
2235
- agent_state[key] = None
2258
+ _clear_session_capture_fields(agent_state)
2236
2259
  if command_agent.get("_session_id"):
2237
2260
  agent_state["_pending_session_id"] = command_agent["_session_id"]
2238
2261
  _capture_agent_session(workspace, agent_id, agent_state, event_log, timeout_s=1.5, exclude_session_ids=known_session_ids)
2239
2262
  if open_display and state.get("display_backend") in {"ghostty", "ghostty_window"}:
2240
2263
  agent_state["display"] = _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)
2264
+ elif open_display and state.get("display_backend") == "ghostty_workspace":
2265
+ agent_state["display"] = _open_ghostty_workspace_agent_display(
2266
+ session_name,
2267
+ agent_id,
2268
+ agent,
2269
+ previous.get("display") or {},
2270
+ event_log,
2271
+ )
2241
2272
  state["agents"][agent_id] = agent_state
2242
2273
  save_runtime_state(workspace, state)
2243
2274
  store = MessageStore(workspace)
@@ -3020,7 +3051,7 @@ def preflight(team_dir: Path) -> dict[str, Any]:
3020
3051
  ok = ok and bool(tmux_path)
3021
3052
  ghostty = _ghostty_command()
3022
3053
  ghostty_check = {"name": "ghostty", "ok": bool(ghostty), "path": ghostty, "required": False}
3023
- if spec and spec.get("runtime", {}).get("display_backend") in {"ghostty", "ghostty_window"}:
3054
+ if spec and spec.get("runtime", {}).get("display_backend") in GHOSTTY_DISPLAY_BACKENDS:
3024
3055
  ghostty_check["required"] = True
3025
3056
  ok = ok and bool(ghostty)
3026
3057
  checks.append(ghostty_check)
@@ -4498,14 +4529,34 @@ def _ghostty_command() -> str | None:
4498
4529
  )
4499
4530
 
4500
4531
 
4532
+ def _ghostty_app_exists() -> bool:
4533
+ return Path("/Applications/Ghostty.app").exists()
4534
+
4535
+
4536
+ def _ghostty_pids_by_title(title: str, wait_s: float = 0.0) -> list[int]:
4537
+ deadline = time.monotonic() + max(wait_s, 0.0)
4538
+ while True:
4539
+ pgrep = run_cmd(["pgrep", "-f", f"--title={title}"], timeout=5)
4540
+ if pgrep.returncode == 0:
4541
+ pids = [int(pid) for pid in pgrep.stdout.split() if pid.isdigit()]
4542
+ if pids:
4543
+ return pids
4544
+ if time.monotonic() >= deadline:
4545
+ return []
4546
+ time.sleep(0.2)
4547
+
4548
+
4501
4549
  def _open_worker_displays(
4502
4550
  workspace: Path,
4503
4551
  session_name: str,
4504
4552
  jobs: list[tuple[str, dict[str, Any]]],
4505
4553
  event_log: EventLog,
4554
+ display_backend: str = "ghostty_window",
4506
4555
  ) -> dict[str, dict[str, Any]]:
4507
4556
  if not jobs:
4508
4557
  return {}
4558
+ if display_backend == "ghostty_workspace":
4559
+ return _open_ghostty_workspace(workspace, session_name, jobs, event_log)
4509
4560
  if len(jobs) == 1:
4510
4561
  agent_id, agent = jobs[0]
4511
4562
  return {agent_id: _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)}
@@ -4540,7 +4591,7 @@ def _open_ghostty_worker_window(
4540
4591
  agent: dict[str, Any],
4541
4592
  event_log: EventLog,
4542
4593
  ) -> dict[str, Any]:
4543
- if not Path("/Applications/Ghostty.app").exists():
4594
+ if not _ghostty_app_exists():
4544
4595
  blocker = {
4545
4596
  "backend": "ghostty_window",
4546
4597
  "status": "blocked",
@@ -4582,15 +4633,129 @@ def _open_ghostty_worker_window(
4582
4633
  if proc.returncode != 0:
4583
4634
  display["reason"] = proc.stderr.strip() or proc.stdout.strip() or "open Ghostty.app failed"
4584
4635
  else:
4585
- time.sleep(0.2)
4586
- pgrep = run_cmd(["pgrep", "-f", f"--title={title}"], timeout=5)
4587
- if pgrep.returncode == 0:
4588
- display["pids"] = [int(pid) for pid in pgrep.stdout.split() if pid.isdigit()]
4589
- display["pid"] = display["pids"][0] if display["pids"] else None
4636
+ display["pids"] = _ghostty_pids_by_title(title, wait_s=3.0)
4637
+ display["pid"] = display["pids"][0] if display["pids"] else None
4590
4638
  event_log.write("display.ghostty_window", agent_id=agent["id"], **display)
4591
4639
  return display
4592
4640
 
4593
4641
 
4642
+ def _open_ghostty_workspace(
4643
+ workspace: Path,
4644
+ session_name: str,
4645
+ jobs: list[tuple[str, dict[str, Any]]],
4646
+ event_log: EventLog,
4647
+ ) -> dict[str, dict[str, Any]]:
4648
+ if not _ghostty_app_exists():
4649
+ return _ghostty_workspace_blocked(jobs, event_log, "ghostty_app_missing")
4650
+ aggregator_session = _ghostty_workspace_aggregator_name(session_name)
4651
+ linked_results = _prepare_ghostty_workspace_linked_sessions(session_name, jobs)
4652
+ displays: dict[str, dict[str, Any]] = {}
4653
+ linked_jobs: list[tuple[str, dict[str, Any], str]] = []
4654
+ for agent_id, agent in jobs:
4655
+ linked = linked_results.get(agent_id, {})
4656
+ linked_session = linked.get("linked_session") or _ghostty_display_session_name(session_name, agent_id)
4657
+ if linked.get("ok"):
4658
+ linked_jobs.append((agent_id, agent, linked_session))
4659
+ continue
4660
+ displays.update(
4661
+ _ghostty_workspace_blocked(
4662
+ [(agent_id, agent)],
4663
+ event_log,
4664
+ linked.get("reason", "display_session_create_failed"),
4665
+ aggregator_session=aggregator_session,
4666
+ linked_sessions={agent_id: linked_session},
4667
+ error=linked.get("error"),
4668
+ target=f"{session_name}:{agent_id}",
4669
+ )
4670
+ )
4671
+ if not linked_jobs:
4672
+ return displays
4673
+ prepared = _prepare_ghostty_workspace_aggregator(aggregator_session, linked_jobs)
4674
+ if not prepared["ok"]:
4675
+ _kill_ghostty_workspace_linked_sessions([linked_session for _agent_id, _agent, linked_session in linked_jobs])
4676
+ displays.update(
4677
+ _ghostty_workspace_blocked(
4678
+ [(agent_id, agent) for agent_id, agent, _linked_session in linked_jobs],
4679
+ event_log,
4680
+ prepared["reason"],
4681
+ aggregator_session=aggregator_session,
4682
+ linked_sessions={agent_id: linked_session for agent_id, _agent, linked_session in linked_jobs},
4683
+ error=prepared.get("error"),
4684
+ target=prepared.get("target"),
4685
+ )
4686
+ )
4687
+ return displays
4688
+ title = f"team-agent:{session_name}:workspace"
4689
+ launch_args = _ghostty_attach_args(aggregator_session, title)
4690
+ proc = run_cmd(launch_args, timeout=10)
4691
+ if proc.returncode != 0:
4692
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4693
+ _kill_ghostty_workspace_linked_sessions([linked_session for _agent_id, _agent, linked_session in linked_jobs])
4694
+ displays.update(
4695
+ _ghostty_workspace_blocked(
4696
+ [(agent_id, agent) for agent_id, agent, _linked_session in linked_jobs],
4697
+ event_log,
4698
+ "open Ghostty.app failed",
4699
+ aggregator_session=aggregator_session,
4700
+ linked_sessions={agent_id: linked_session for agent_id, _agent, linked_session in linked_jobs},
4701
+ error=proc.stderr.strip() or proc.stdout.strip(),
4702
+ )
4703
+ )
4704
+ return displays
4705
+ pids = _ghostty_pids_by_title(title, wait_s=3.0)
4706
+ panes = {pane["agent_id"]: pane for pane in prepared["panes"]}
4707
+ for agent_id, agent, linked_session in linked_jobs:
4708
+ pane = panes.get(agent_id, {})
4709
+ display = {
4710
+ "backend": "ghostty_workspace",
4711
+ "status": "opened",
4712
+ "title": title,
4713
+ "pane_title": pane.get("title") or _ghostty_workspace_pane_title(agent),
4714
+ "target": f"{session_name}:{agent_id}",
4715
+ "linked_session": linked_session,
4716
+ "aggregator_session": aggregator_session,
4717
+ "display_session": aggregator_session,
4718
+ "pane_id": pane.get("pane_id"),
4719
+ "launch_args": launch_args,
4720
+ "pid": pids[0] if pids else None,
4721
+ "pids": pids,
4722
+ "tty": None,
4723
+ "fallback": "tmux_headless",
4724
+ "note": "Ghostty opens one aggregator tmux session; each pane attaches to a distinct linked session pinned to one base worker window, so runtime injection remains session:agent_id addressed.",
4725
+ }
4726
+ event_log.write("display.ghostty_workspace", agent_id=agent_id, **display)
4727
+ displays[agent_id] = display
4728
+ return displays
4729
+
4730
+
4731
+ def _ghostty_workspace_blocked(
4732
+ jobs: list[tuple[str, dict[str, Any]]],
4733
+ event_log: EventLog,
4734
+ reason: str,
4735
+ aggregator_session: str | None = None,
4736
+ linked_sessions: dict[str, str] | None = None,
4737
+ error: str | None = None,
4738
+ target: str | None = None,
4739
+ ) -> dict[str, dict[str, Any]]:
4740
+ displays: dict[str, dict[str, Any]] = {}
4741
+ for agent_id, _agent in jobs:
4742
+ linked_session = (linked_sessions or {}).get(agent_id)
4743
+ display = {
4744
+ "backend": "ghostty_workspace",
4745
+ "status": "blocked",
4746
+ "reason": reason,
4747
+ "error": error,
4748
+ "target": target or f"{agent_id}",
4749
+ "linked_session": linked_session,
4750
+ "aggregator_session": aggregator_session,
4751
+ "display_session": aggregator_session,
4752
+ "fallback": "tmux_headless",
4753
+ }
4754
+ event_log.write("display.ghostty_workspace_blocked", agent_id=agent_id, **display)
4755
+ displays[agent_id] = display
4756
+ return displays
4757
+
4758
+
4594
4759
  def _ghostty_display_session_name(session_name: str, window_name: str) -> str:
4595
4760
  raw = f"{session_name}:{window_name}"
4596
4761
  digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
@@ -4618,6 +4783,284 @@ def _prepare_ghostty_display_session(session_name: str, window_name: str, displa
4618
4783
  return {"ok": True, "display_session": display_session}
4619
4784
 
4620
4785
 
4786
+ def _ghostty_workspace_aggregator_name(session_name: str) -> str:
4787
+ raw = f"{session_name}:workspace"
4788
+ digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
4789
+ safe_session = re.sub(r"[^A-Za-z0-9_.-]", "_", session_name)[:80].strip("._-") or "team"
4790
+ return f"{safe_session}__display__workspace__{digest}"
4791
+
4792
+
4793
+ def _ghostty_workspace_pane_command(linked_session: str) -> str:
4794
+ return f"TMUX= tmux attach-session -t {shlex.quote(linked_session)}"
4795
+
4796
+
4797
+ def _ghostty_workspace_pane_title(agent: dict[str, Any]) -> str:
4798
+ return f"team-agent:{agent['id']}:{agent.get('role', '')}"
4799
+
4800
+
4801
+ def _prepare_ghostty_workspace_linked_sessions(
4802
+ session_name: str,
4803
+ jobs: list[tuple[str, dict[str, Any]]],
4804
+ ) -> dict[str, dict[str, Any]]:
4805
+ def prepare(agent_id: str) -> dict[str, Any]:
4806
+ linked_session = _ghostty_display_session_name(session_name, agent_id)
4807
+ result = _prepare_ghostty_display_session(session_name, agent_id, linked_session)
4808
+ result["linked_session"] = linked_session
4809
+ return result
4810
+
4811
+ if len(jobs) == 1:
4812
+ agent_id, _agent = jobs[0]
4813
+ return {agent_id: prepare(agent_id)}
4814
+ results: dict[str, dict[str, Any]] = {}
4815
+ max_workers = min(4, len(jobs))
4816
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
4817
+ futures = {executor.submit(prepare, agent_id): agent_id for agent_id, _agent in jobs}
4818
+ for future in as_completed(futures):
4819
+ agent_id = futures[future]
4820
+ try:
4821
+ results[agent_id] = future.result()
4822
+ except Exception as exc:
4823
+ results[agent_id] = {
4824
+ "ok": False,
4825
+ "reason": "display_session_create_exception",
4826
+ "error": str(exc),
4827
+ "linked_session": _ghostty_display_session_name(session_name, agent_id),
4828
+ }
4829
+ return results
4830
+
4831
+
4832
+ def _prepare_ghostty_workspace_aggregator(
4833
+ aggregator_session: str,
4834
+ linked_jobs: list[tuple[str, dict[str, Any], str]],
4835
+ ) -> dict[str, Any]:
4836
+ window_name = "overview"
4837
+ if _tmux_session_exists(aggregator_session):
4838
+ proc = run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4839
+ if proc.returncode != 0:
4840
+ return {"ok": False, "reason": "display_session_cleanup_failed", "error": proc.stderr.strip()}
4841
+
4842
+ panes: list[dict[str, Any]] = []
4843
+ first_agent_id, first_agent, first_linked_session = linked_jobs[0]
4844
+ proc = run_cmd(
4845
+ [
4846
+ "tmux",
4847
+ "new-session",
4848
+ "-d",
4849
+ "-P",
4850
+ "-F",
4851
+ "#{pane_id}",
4852
+ "-s",
4853
+ aggregator_session,
4854
+ "-n",
4855
+ window_name,
4856
+ _ghostty_workspace_pane_command(first_linked_session),
4857
+ ],
4858
+ timeout=10,
4859
+ )
4860
+ if proc.returncode != 0:
4861
+ return {"ok": False, "reason": "display_session_create_failed", "error": proc.stderr.strip()}
4862
+ first_pane_id = _tmux_stdout_last_line(proc.stdout) or f"{aggregator_session}:{window_name}.0"
4863
+ first_title = _ghostty_workspace_pane_title(first_agent)
4864
+ title_result = _set_ghostty_workspace_pane_title(first_pane_id, first_title)
4865
+ if not title_result["ok"]:
4866
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4867
+ return title_result
4868
+ panes.append({"agent_id": first_agent_id, "pane_id": first_pane_id, "title": first_title, "linked_session": first_linked_session})
4869
+
4870
+ proc = run_cmd(["tmux", "set-window-option", "-t", f"{aggregator_session}:{window_name}", "remain-on-exit", "on"], timeout=10)
4871
+ if proc.returncode != 0:
4872
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4873
+ return {"ok": False, "reason": "display_session_remain_on_exit_failed", "error": proc.stderr.strip()}
4874
+
4875
+ for index, (agent_id, agent, linked_session) in enumerate(linked_jobs[1:], start=1):
4876
+ proc = run_cmd(
4877
+ [
4878
+ "tmux",
4879
+ "split-window",
4880
+ "-t",
4881
+ f"{aggregator_session}:{window_name}",
4882
+ "-h",
4883
+ "-P",
4884
+ "-F",
4885
+ "#{pane_id}",
4886
+ _ghostty_workspace_pane_command(linked_session),
4887
+ ],
4888
+ timeout=10,
4889
+ )
4890
+ if proc.returncode != 0:
4891
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4892
+ return {"ok": False, "reason": "display_session_split_failed", "error": proc.stderr.strip(), "target": linked_session}
4893
+ pane_id = _tmux_stdout_last_line(proc.stdout) or f"{aggregator_session}:{window_name}.{index}"
4894
+ title = _ghostty_workspace_pane_title(agent)
4895
+ title_result = _set_ghostty_workspace_pane_title(pane_id, title)
4896
+ if not title_result["ok"]:
4897
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4898
+ return title_result
4899
+ panes.append({"agent_id": agent_id, "pane_id": pane_id, "title": title, "linked_session": linked_session})
4900
+
4901
+ proc = run_cmd(["tmux", "select-layout", "-t", f"{aggregator_session}:{window_name}", "even-horizontal"], timeout=10)
4902
+ if proc.returncode != 0:
4903
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4904
+ return {"ok": False, "reason": "display_session_layout_failed", "error": proc.stderr.strip()}
4905
+ return {"ok": True, "aggregator_session": aggregator_session, "panes": panes}
4906
+
4907
+
4908
+ def _set_ghostty_workspace_pane_title(pane_id: str, title: str) -> dict[str, Any]:
4909
+ proc = run_cmd(["tmux", "select-pane", "-t", pane_id, "-T", title], timeout=10)
4910
+ if proc.returncode != 0:
4911
+ return {"ok": False, "reason": "display_session_pane_title_failed", "error": proc.stderr.strip()}
4912
+ return {"ok": True}
4913
+
4914
+
4915
+ def _tmux_stdout_last_line(stdout: str) -> str | None:
4916
+ lines = [line.strip() for line in stdout.splitlines() if line.strip()]
4917
+ return lines[-1] if lines else None
4918
+
4919
+
4920
+ def _open_ghostty_workspace_agent_display(
4921
+ session_name: str,
4922
+ agent_id: str,
4923
+ agent: dict[str, Any],
4924
+ previous_display: dict[str, Any],
4925
+ event_log: EventLog,
4926
+ ) -> dict[str, Any]:
4927
+ if not _ghostty_app_exists():
4928
+ return _ghostty_workspace_blocked(
4929
+ [(agent_id, agent)],
4930
+ event_log,
4931
+ "ghostty_app_missing",
4932
+ aggregator_session=_ghostty_workspace_aggregator_name(session_name),
4933
+ linked_sessions={agent_id: _ghostty_display_session_name(session_name, agent_id)},
4934
+ target=f"{session_name}:{agent_id}",
4935
+ )[agent_id]
4936
+ aggregator_session = str(
4937
+ previous_display.get("aggregator_session")
4938
+ or previous_display.get("display_session")
4939
+ or _ghostty_workspace_aggregator_name(session_name)
4940
+ )
4941
+ linked_session = _ghostty_display_session_name(session_name, agent_id)
4942
+ prepared = _prepare_ghostty_display_session(session_name, agent_id, linked_session)
4943
+ if not prepared["ok"]:
4944
+ return _ghostty_workspace_blocked(
4945
+ [(agent_id, agent)],
4946
+ event_log,
4947
+ prepared["reason"],
4948
+ aggregator_session=aggregator_session,
4949
+ linked_sessions={agent_id: linked_session},
4950
+ error=prepared.get("error"),
4951
+ target=f"{session_name}:{agent_id}",
4952
+ )[agent_id]
4953
+ if not _tmux_session_exists(aggregator_session):
4954
+ return _ghostty_workspace_partial_update_display(
4955
+ session_name,
4956
+ agent_id,
4957
+ agent,
4958
+ event_log,
4959
+ reason="aggregator_session_missing",
4960
+ note="pane refresh requires full team restart",
4961
+ )
4962
+
4963
+ pane_title = _ghostty_workspace_pane_title(agent)
4964
+ command = _ghostty_workspace_pane_command(linked_session)
4965
+ pane_id = str(previous_display.get("pane_id") or "")
4966
+ refreshed = False
4967
+ if pane_id:
4968
+ proc = run_cmd(["tmux", "respawn-pane", "-k", "-t", pane_id, command], timeout=10)
4969
+ refreshed = proc.returncode == 0
4970
+ if not refreshed:
4971
+ proc = run_cmd(
4972
+ [
4973
+ "tmux",
4974
+ "split-window",
4975
+ "-t",
4976
+ f"{aggregator_session}:overview",
4977
+ "-h",
4978
+ "-P",
4979
+ "-F",
4980
+ "#{pane_id}",
4981
+ command,
4982
+ ],
4983
+ timeout=10,
4984
+ )
4985
+ if proc.returncode != 0:
4986
+ return _ghostty_workspace_partial_update_display(
4987
+ session_name,
4988
+ agent_id,
4989
+ agent,
4990
+ event_log,
4991
+ reason="aggregator_pane_refresh_failed",
4992
+ note=proc.stderr.strip() or "pane refresh requires full team restart",
4993
+ )
4994
+ pane_id = _tmux_stdout_last_line(proc.stdout) or pane_id
4995
+ title_result = _set_ghostty_workspace_pane_title(pane_id, pane_title)
4996
+ if not title_result["ok"]:
4997
+ return _ghostty_workspace_partial_update_display(
4998
+ session_name,
4999
+ agent_id,
5000
+ agent,
5001
+ event_log,
5002
+ reason=title_result["reason"],
5003
+ note=title_result.get("error") or "pane refresh requires full team restart",
5004
+ )
5005
+ run_cmd(["tmux", "select-layout", "-t", f"{aggregator_session}:overview", "even-horizontal"], timeout=10)
5006
+ title = str(previous_display.get("title") or f"team-agent:{session_name}:workspace")
5007
+ pids = [int(pid) for pid in previous_display.get("pids", []) if str(pid).isdigit()]
5008
+ display = {
5009
+ "backend": "ghostty_workspace",
5010
+ "status": "opened",
5011
+ "title": title,
5012
+ "pane_title": pane_title,
5013
+ "target": f"{session_name}:{agent_id}",
5014
+ "linked_session": linked_session,
5015
+ "aggregator_session": aggregator_session,
5016
+ "display_session": aggregator_session,
5017
+ "pane_id": pane_id,
5018
+ "pid": pids[0] if pids else None,
5019
+ "pids": pids,
5020
+ "tty": None,
5021
+ "fallback": "tmux_headless",
5022
+ "note": "Refreshed this worker's Ghostty workspace pane by respawning it against a distinct linked session.",
5023
+ }
5024
+ event_log.write("display.ghostty_workspace", agent_id=agent_id, **display)
5025
+ return display
5026
+
5027
+
5028
+ def _ghostty_workspace_partial_update_display(
5029
+ session_name: str,
5030
+ agent_id: str,
5031
+ agent: dict[str, Any],
5032
+ event_log: EventLog,
5033
+ reason: str = "partial_update_requires_team_restart",
5034
+ note: str = "pane refresh requires full team restart",
5035
+ ) -> dict[str, Any]:
5036
+ aggregator_session = _ghostty_workspace_aggregator_name(session_name)
5037
+ display = {
5038
+ "backend": "ghostty_workspace",
5039
+ "status": "blocked",
5040
+ "reason": reason,
5041
+ "target": f"{session_name}:{agent_id}",
5042
+ "linked_session": _ghostty_display_session_name(session_name, agent_id),
5043
+ "aggregator_session": aggregator_session,
5044
+ "display_session": aggregator_session,
5045
+ "pane_title": _ghostty_workspace_pane_title(agent),
5046
+ "fallback": "tmux_headless",
5047
+ "note": note,
5048
+ "action": "restart the team to rebuild the Ghostty workspace layout",
5049
+ }
5050
+ event_log.write("display.ghostty_workspace_partial_update", agent_id=agent_id, **display)
5051
+ return display
5052
+
5053
+
5054
+ def _kill_ghostty_workspace_linked_sessions(linked_sessions: list[str]) -> list[str]:
5055
+ killed: list[str] = []
5056
+ for linked_session in dict.fromkeys(linked_sessions):
5057
+ if _tmux_session_exists(linked_session):
5058
+ proc = run_cmd(["tmux", "kill-session", "-t", linked_session], timeout=10)
5059
+ if proc.returncode == 0:
5060
+ killed.append(linked_session)
5061
+ return killed
5062
+
5063
+
4621
5064
  def _ghostty_attach_args(display_session: str, title: str) -> list[str]:
4622
5065
  return [
4623
5066
  "open",
@@ -4633,7 +5076,11 @@ def _ghostty_attach_args(display_session: str, title: str) -> list[str]:
4633
5076
  ]
4634
5077
 
4635
5078
 
4636
- def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log: EventLog) -> None:
5079
+ def _close_ghostty_display(
5080
+ agent_id: str,
5081
+ agent_state: dict[str, Any],
5082
+ event_log: EventLog,
5083
+ ) -> None:
4637
5084
  display = agent_state.get("display") or {}
4638
5085
  if display.get("backend") != "ghostty_window":
4639
5086
  return
@@ -4641,9 +5088,7 @@ def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log
4641
5088
  pids = [str(pid) for pid in display.get("pids", []) if str(pid).isdigit()]
4642
5089
  title = display.get("title")
4643
5090
  if not pids and title:
4644
- pgrep = run_cmd(["pgrep", "-f", f"--title={title}"], timeout=5)
4645
- if pgrep.returncode == 0:
4646
- pids = [pid for pid in pgrep.stdout.split() if pid.isdigit()]
5091
+ pids = [str(pid) for pid in _ghostty_pids_by_title(str(title))]
4647
5092
  killed: list[str] = []
4648
5093
  for pid in pids:
4649
5094
  proc = run_cmd(["kill", pid], timeout=5)
@@ -4664,6 +5109,66 @@ def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log
4664
5109
  )
4665
5110
 
4666
5111
 
5112
+ def _close_ghostty_workspace(state: dict[str, Any], event_log: EventLog) -> None:
5113
+ displays = [
5114
+ (agent_id, agent_state.get("display") or {})
5115
+ for agent_id, agent_state in state.get("agents", {}).items()
5116
+ if (agent_state.get("display") or {}).get("backend") == "ghostty_workspace"
5117
+ ]
5118
+ if not displays:
5119
+ return
5120
+ aggregator_session = next(
5121
+ (
5122
+ str(display.get("aggregator_session") or display.get("display_session"))
5123
+ for _agent_id, display in displays
5124
+ if display.get("aggregator_session") or display.get("display_session")
5125
+ ),
5126
+ None,
5127
+ )
5128
+ title = next((str(display.get("title")) for _agent_id, display in displays if display.get("title")), None)
5129
+ pids = {
5130
+ str(pid)
5131
+ for _agent_id, display in displays
5132
+ for pid in display.get("pids", [])
5133
+ if str(pid).isdigit()
5134
+ }
5135
+ if not pids and title:
5136
+ pids = {str(pid) for pid in _ghostty_pids_by_title(str(title))}
5137
+
5138
+ aggregator_closed = False
5139
+ if aggregator_session and _tmux_session_exists(aggregator_session):
5140
+ proc = run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
5141
+ if proc.returncode == 0:
5142
+ aggregator_closed = True
5143
+ else:
5144
+ event_log.write(
5145
+ "display.ghostty_workspace_close_failed",
5146
+ aggregator_session=aggregator_session,
5147
+ error=proc.stderr.strip(),
5148
+ )
5149
+
5150
+ linked_sessions = [
5151
+ str(display.get("linked_session"))
5152
+ for _agent_id, display in displays
5153
+ if display.get("linked_session")
5154
+ ]
5155
+ linked_closed = _kill_ghostty_workspace_linked_sessions(linked_sessions)
5156
+
5157
+ killed: list[str] = []
5158
+ for pid in sorted(pids):
5159
+ proc = run_cmd(["kill", pid], timeout=5)
5160
+ if proc.returncode == 0:
5161
+ killed.append(pid)
5162
+ event_log.write(
5163
+ "display.ghostty_workspace_closed",
5164
+ pids=killed,
5165
+ title=title,
5166
+ aggregator_session=aggregator_session,
5167
+ linked_sessions=linked_closed,
5168
+ aggregator_closed=aggregator_closed,
5169
+ )
5170
+
5171
+
4667
5172
  def get_adapter_or_raise(name: str) -> str:
4668
5173
  if name == "tmux" and not shutil_which("tmux"):
4669
5174
  raise RuntimeError("tmux is not installed; install tmux 3.3+ before launch")