@team-agent/installer 0.1.7 → 0.1.10

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.
@@ -54,6 +54,8 @@ TMUX_PANE_FORMAT = (
54
54
  "#{pane_current_path}\t#{session_attached}"
55
55
  )
56
56
  HEALTH_STATUSES = {"RUNNING", "IDLE", "AWAITING_APPROVAL", "BLOCKED", "ERROR", "DONE"}
57
+ GHOSTTY_DISPLAY_BACKENDS = {"ghostty", "ghostty_window", "ghostty_workspace"}
58
+ GHOSTTY_WORKSPACE_PANES_PER_WINDOW = 3
57
59
  PEEK_MAX_LINES = 80
58
60
  PEEK_SEARCH_SCAN_LINES = 300
59
61
  PEEK_MAX_MATCHES = 5
@@ -226,6 +228,22 @@ def _spec_team_dir(spec_path: Path, workspace: Path) -> Path:
226
228
  return workspace.resolve() / ".team" / "current"
227
229
 
228
230
 
231
+ def _is_team_doc_dir(team_dir: Path) -> bool:
232
+ return (team_dir / "TEAM.md").exists() and (team_dir / "agents").is_dir()
233
+
234
+
235
+ def _compile_team_dir_spec(team_dir: Path, workspace: Path) -> dict[str, Any]:
236
+ from team_agent.compiler import compile_team
237
+
238
+ spec_path = team_dir / "team.spec.yaml"
239
+ compiled = compile_team(team_dir, spec_path)
240
+ if compiled["spec"].get("context", {}).get("state_file") == "team_state.md":
241
+ state_file = str(team_dir.relative_to(workspace) / "team_state.md") if team_dir.is_relative_to(workspace) else "team_state.md"
242
+ compiled["spec"]["context"]["state_file"] = state_file
243
+ spec_path.write_text(dumps(compiled["spec"]), encoding="utf-8")
244
+ return compiled
245
+
246
+
229
247
  def _attach_team_profile_dirs(spec: dict[str, Any], spec_path: Path, workspace: Path | None = None, team_dir: Path | None = None) -> None:
230
248
  workspace = workspace.resolve() if workspace else workspace_from_spec(spec, spec_path)
231
249
  team_dir = team_dir.resolve() if team_dir else _spec_team_dir(spec_path, workspace)
@@ -456,10 +474,16 @@ def launch(
456
474
  timeout_s=1.5,
457
475
  exclude_session_ids=known_session_ids,
458
476
  )
459
- if state.get("display_backend") in {"ghostty", "ghostty_window"}:
477
+ if state.get("display_backend") in GHOSTTY_DISPLAY_BACKENDS:
460
478
  display_jobs.append((agent["id"], agent))
461
479
  started.append({"agent_id": agent["id"], "provider": agent["provider"], "window": agent["id"]})
462
- for agent_id, display in _open_worker_displays(workspace, session_name, display_jobs, event_log).items():
480
+ for agent_id, display in _open_worker_displays(
481
+ workspace,
482
+ session_name,
483
+ display_jobs,
484
+ event_log,
485
+ state.get("display_backend", "none"),
486
+ ).items():
463
487
  if agent_id in state["agents"]:
464
488
  state["agents"][agent_id]["display"] = display
465
489
  save_runtime_state(workspace, state)
@@ -1753,15 +1777,21 @@ def shutdown(workspace: Path, keep_logs: bool = True) -> dict[str, Any]:
1753
1777
  if proc.returncode == 0:
1754
1778
  log_path.write_text(proc.stdout, encoding="utf-8")
1755
1779
  captured.append(str(log_path))
1780
+ _close_ghostty_workspace(state, event_log)
1756
1781
  for agent_id, agent_state in state.get("agents", {}).items():
1757
1782
  _close_ghostty_display(agent_id, agent_state, event_log)
1758
1783
  closed_displays.add(agent_id)
1759
1784
  proc = run_cmd(["tmux", "kill-session", "-t", session_name], timeout=10)
1760
1785
  if proc.returncode != 0:
1761
- raise RuntimeError(f"tmux kill-session failed: {proc.stderr.strip()}")
1762
- event_log.write("shutdown.kill_session", session=session_name, keep_logs=keep_logs, captured=captured)
1786
+ if "can't find session" in proc.stderr:
1787
+ event_log.write("shutdown.idempotent", session=session_name, reason="session disappeared before kill")
1788
+ else:
1789
+ raise RuntimeError(f"tmux kill-session failed: {proc.stderr.strip()}")
1790
+ else:
1791
+ event_log.write("shutdown.kill_session", session=session_name, keep_logs=keep_logs, captured=captured)
1763
1792
  else:
1764
1793
  event_log.write("shutdown.idempotent", session=session_name, reason="session missing")
1794
+ _close_ghostty_workspace(state, event_log)
1765
1795
  for agent_id, agent_state in state.get("agents", {}).items():
1766
1796
  if agent_id not in closed_displays:
1767
1797
  _close_ghostty_display(agent_id, agent_state, event_log)
@@ -1792,10 +1822,16 @@ def shutdown(workspace: Path, keep_logs: bool = True) -> dict[str, Any]:
1792
1822
  def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None) -> dict[str, Any]:
1793
1823
  state = _select_restart_state(workspace, team)
1794
1824
  spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
1795
- if not spec_path.exists():
1796
- raise RuntimeError(f"missing spec for restart: {spec_path}")
1797
- spec = load_spec(spec_path)
1798
1825
  team_dir = Path(str(state.get("team_dir"))) if state.get("team_dir") else _spec_team_dir(spec_path, workspace)
1826
+ if _is_team_doc_dir(team_dir):
1827
+ compiled = _compile_team_dir_spec(team_dir, workspace)
1828
+ spec = compiled["spec"]
1829
+ spec_path = team_dir / "team.spec.yaml"
1830
+ state["spec_path"] = str(spec_path)
1831
+ else:
1832
+ if not spec_path.exists():
1833
+ raise RuntimeError(f"missing spec for restart: {spec_path}")
1834
+ spec = load_spec(spec_path)
1799
1835
  _attach_team_profile_dirs(spec, spec_path, workspace, team_dir)
1800
1836
  ensure_workspace_dirs(workspace)
1801
1837
  event_log = EventLog(workspace)
@@ -1810,6 +1846,9 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
1810
1846
  raise RuntimeError(_tmux_session_conflict_error(session_name))
1811
1847
  runtime_cfg = _effective_runtime_config(spec.get("runtime", {}))
1812
1848
  display_backend = spec.get("runtime", {}).get("display_backend", state.get("display_backend", "none"))
1849
+ _close_ghostty_workspace(state, event_log)
1850
+ for agent_id, agent_state in state.get("agents", {}).items():
1851
+ _close_ghostty_display(agent_id, agent_state, event_log)
1813
1852
  state["display_backend"] = display_backend
1814
1853
  restart_agents = [
1815
1854
  agent
@@ -1995,7 +2034,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
1995
2034
  timeout_s=1.5,
1996
2035
  exclude_session_ids=known_session_ids,
1997
2036
  )
1998
- if display_backend in {"ghostty", "ghostty_window"}:
2037
+ if display_backend in GHOSTTY_DISPLAY_BACKENDS:
1999
2038
  display_jobs.append((agent["id"], agent))
2000
2039
  new_agents[agent["id"]] = agent_state
2001
2040
  restarted.append(
@@ -2006,7 +2045,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
2006
2045
  "display_target": None,
2007
2046
  }
2008
2047
  )
2009
- display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log)
2048
+ display_results = _open_worker_displays(workspace, session_name, display_jobs, event_log, display_backend)
2010
2049
  for agent_id, display in display_results.items():
2011
2050
  if agent_id in new_agents:
2012
2051
  new_agents[agent_id]["display"] = display
@@ -2072,6 +2111,10 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
2072
2111
  display = agent_state.get("display") or {}
2073
2112
  if display.get("status") != "opened":
2074
2113
  agent_state["display"] = _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)
2114
+ elif open_display and state.get("display_backend") == "ghostty_workspace":
2115
+ display = agent_state.get("display") or {}
2116
+ if display.get("status") != "opened":
2117
+ agent_state["display"] = _open_ghostty_workspace_agent_display(session_name, agent_id, agent, display, event_log)
2075
2118
  state["agents"][agent_id] = agent_state
2076
2119
  save_runtime_state(workspace, state)
2077
2120
  write_team_state(workspace, spec, state)
@@ -2248,6 +2291,14 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
2248
2291
  _capture_agent_session(workspace, agent_id, agent_state, event_log, timeout_s=1.5, exclude_session_ids=known_session_ids)
2249
2292
  if open_display and state.get("display_backend") in {"ghostty", "ghostty_window"}:
2250
2293
  agent_state["display"] = _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)
2294
+ elif open_display and state.get("display_backend") == "ghostty_workspace":
2295
+ agent_state["display"] = _open_ghostty_workspace_agent_display(
2296
+ session_name,
2297
+ agent_id,
2298
+ agent,
2299
+ previous.get("display") or {},
2300
+ event_log,
2301
+ )
2251
2302
  state["agents"][agent_id] = agent_state
2252
2303
  save_runtime_state(workspace, state)
2253
2304
  store = MessageStore(workspace)
@@ -3030,7 +3081,7 @@ def preflight(team_dir: Path) -> dict[str, Any]:
3030
3081
  ok = ok and bool(tmux_path)
3031
3082
  ghostty = _ghostty_command()
3032
3083
  ghostty_check = {"name": "ghostty", "ok": bool(ghostty), "path": ghostty, "required": False}
3033
- if spec and spec.get("runtime", {}).get("display_backend") in {"ghostty", "ghostty_window"}:
3084
+ if spec and spec.get("runtime", {}).get("display_backend") in GHOSTTY_DISPLAY_BACKENDS:
3034
3085
  ghostty_check["required"] = True
3035
3086
  ok = ok and bool(ghostty)
3036
3087
  checks.append(ghostty_check)
@@ -3105,18 +3156,12 @@ def quick_start(
3105
3156
  fresh: bool = False,
3106
3157
  team_id: str | None = None,
3107
3158
  ) -> dict[str, Any]:
3108
- from team_agent.compiler import compile_team
3109
-
3110
3159
  team_dir = _prepare_quick_start_team(agents_dir.resolve(), Path.cwd().resolve(), name, team_id=team_id)
3111
3160
  workspace = team_workspace(team_dir)
3112
3161
  ensure_workspace_dirs(workspace)
3113
3162
  _ensure_profiles_for_roles(team_dir)
3163
+ compiled = _compile_team_dir_spec(team_dir, workspace)
3114
3164
  spec_path = team_dir / "team.spec.yaml"
3115
- compiled = compile_team(team_dir, spec_path)
3116
- if compiled["spec"].get("context", {}).get("state_file") == "team_state.md":
3117
- state_file = str(team_dir.relative_to(workspace) / "team_state.md") if team_dir.is_relative_to(workspace) else "team_state.md"
3118
- compiled["spec"]["context"]["state_file"] = state_file
3119
- spec_path.write_text(dumps(compiled["spec"]), encoding="utf-8")
3120
3165
  existing = _quick_start_existing_context(workspace, compiled["spec"]["runtime"]["session_name"])
3121
3166
  if existing and not fresh:
3122
3167
  return {
@@ -4508,14 +4553,34 @@ def _ghostty_command() -> str | None:
4508
4553
  )
4509
4554
 
4510
4555
 
4556
+ def _ghostty_app_exists() -> bool:
4557
+ return Path("/Applications/Ghostty.app").exists()
4558
+
4559
+
4560
+ def _ghostty_pids_by_title(title: str, wait_s: float = 0.0) -> list[int]:
4561
+ deadline = time.monotonic() + max(wait_s, 0.0)
4562
+ while True:
4563
+ pgrep = run_cmd(["pgrep", "-f", f"--title={title}"], timeout=5)
4564
+ if pgrep.returncode == 0:
4565
+ pids = [int(pid) for pid in pgrep.stdout.split() if pid.isdigit()]
4566
+ if pids:
4567
+ return pids
4568
+ if time.monotonic() >= deadline:
4569
+ return []
4570
+ time.sleep(0.2)
4571
+
4572
+
4511
4573
  def _open_worker_displays(
4512
4574
  workspace: Path,
4513
4575
  session_name: str,
4514
4576
  jobs: list[tuple[str, dict[str, Any]]],
4515
4577
  event_log: EventLog,
4578
+ display_backend: str = "ghostty_window",
4516
4579
  ) -> dict[str, dict[str, Any]]:
4517
4580
  if not jobs:
4518
4581
  return {}
4582
+ if display_backend == "ghostty_workspace":
4583
+ return _open_ghostty_workspace(workspace, session_name, jobs, event_log)
4519
4584
  if len(jobs) == 1:
4520
4585
  agent_id, agent = jobs[0]
4521
4586
  return {agent_id: _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)}
@@ -4550,7 +4615,7 @@ def _open_ghostty_worker_window(
4550
4615
  agent: dict[str, Any],
4551
4616
  event_log: EventLog,
4552
4617
  ) -> dict[str, Any]:
4553
- if not Path("/Applications/Ghostty.app").exists():
4618
+ if not _ghostty_app_exists():
4554
4619
  blocker = {
4555
4620
  "backend": "ghostty_window",
4556
4621
  "status": "blocked",
@@ -4592,15 +4657,130 @@ def _open_ghostty_worker_window(
4592
4657
  if proc.returncode != 0:
4593
4658
  display["reason"] = proc.stderr.strip() or proc.stdout.strip() or "open Ghostty.app failed"
4594
4659
  else:
4595
- time.sleep(0.2)
4596
- pgrep = run_cmd(["pgrep", "-f", f"--title={title}"], timeout=5)
4597
- if pgrep.returncode == 0:
4598
- display["pids"] = [int(pid) for pid in pgrep.stdout.split() if pid.isdigit()]
4599
- display["pid"] = display["pids"][0] if display["pids"] else None
4660
+ display["pids"] = _ghostty_pids_by_title(title, wait_s=3.0)
4661
+ display["pid"] = display["pids"][0] if display["pids"] else None
4600
4662
  event_log.write("display.ghostty_window", agent_id=agent["id"], **display)
4601
4663
  return display
4602
4664
 
4603
4665
 
4666
+ def _open_ghostty_workspace(
4667
+ workspace: Path,
4668
+ session_name: str,
4669
+ jobs: list[tuple[str, dict[str, Any]]],
4670
+ event_log: EventLog,
4671
+ ) -> dict[str, dict[str, Any]]:
4672
+ if not _ghostty_app_exists():
4673
+ return _ghostty_workspace_blocked(jobs, event_log, "ghostty_app_missing")
4674
+ aggregator_session = _ghostty_workspace_aggregator_name(session_name)
4675
+ linked_results = _prepare_ghostty_workspace_linked_sessions(session_name, jobs)
4676
+ displays: dict[str, dict[str, Any]] = {}
4677
+ linked_jobs: list[tuple[str, dict[str, Any], str]] = []
4678
+ for agent_id, agent in jobs:
4679
+ linked = linked_results.get(agent_id, {})
4680
+ linked_session = linked.get("linked_session") or _ghostty_display_session_name(session_name, agent_id)
4681
+ if linked.get("ok"):
4682
+ linked_jobs.append((agent_id, agent, linked_session))
4683
+ continue
4684
+ displays.update(
4685
+ _ghostty_workspace_blocked(
4686
+ [(agent_id, agent)],
4687
+ event_log,
4688
+ linked.get("reason", "display_session_create_failed"),
4689
+ aggregator_session=aggregator_session,
4690
+ linked_sessions={agent_id: linked_session},
4691
+ error=linked.get("error"),
4692
+ target=f"{session_name}:{agent_id}",
4693
+ )
4694
+ )
4695
+ if not linked_jobs:
4696
+ return displays
4697
+ prepared = _prepare_ghostty_workspace_aggregator(aggregator_session, linked_jobs)
4698
+ if not prepared["ok"]:
4699
+ _kill_ghostty_workspace_linked_sessions([linked_session for _agent_id, _agent, linked_session in linked_jobs])
4700
+ displays.update(
4701
+ _ghostty_workspace_blocked(
4702
+ [(agent_id, agent) for agent_id, agent, _linked_session in linked_jobs],
4703
+ event_log,
4704
+ prepared["reason"],
4705
+ aggregator_session=aggregator_session,
4706
+ linked_sessions={agent_id: linked_session for agent_id, _agent, linked_session in linked_jobs},
4707
+ error=prepared.get("error"),
4708
+ target=prepared.get("target"),
4709
+ )
4710
+ )
4711
+ return displays
4712
+ title = f"team-agent:{session_name}:workspace"
4713
+ launch_args = _ghostty_attach_args(aggregator_session, title)
4714
+ proc = run_cmd(launch_args, timeout=10)
4715
+ if proc.returncode != 0:
4716
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4717
+ _kill_ghostty_workspace_linked_sessions([linked_session for _agent_id, _agent, linked_session in linked_jobs])
4718
+ displays.update(
4719
+ _ghostty_workspace_blocked(
4720
+ [(agent_id, agent) for agent_id, agent, _linked_session in linked_jobs],
4721
+ event_log,
4722
+ "open Ghostty.app failed",
4723
+ aggregator_session=aggregator_session,
4724
+ linked_sessions={agent_id: linked_session for agent_id, _agent, linked_session in linked_jobs},
4725
+ error=proc.stderr.strip() or proc.stdout.strip(),
4726
+ )
4727
+ )
4728
+ return displays
4729
+ pids = _ghostty_pids_by_title(title, wait_s=3.0)
4730
+ panes = {pane["agent_id"]: pane for pane in prepared["panes"]}
4731
+ for agent_id, agent, linked_session in linked_jobs:
4732
+ pane = panes.get(agent_id, {})
4733
+ display = {
4734
+ "backend": "ghostty_workspace",
4735
+ "status": "opened",
4736
+ "title": title,
4737
+ "pane_title": pane.get("title") or _ghostty_workspace_pane_title(agent),
4738
+ "target": f"{session_name}:{agent_id}",
4739
+ "linked_session": linked_session,
4740
+ "aggregator_session": aggregator_session,
4741
+ "display_session": aggregator_session,
4742
+ "workspace_window": pane.get("window_name"),
4743
+ "pane_id": pane.get("pane_id"),
4744
+ "launch_args": launch_args,
4745
+ "pid": pids[0] if pids else None,
4746
+ "pids": pids,
4747
+ "tty": None,
4748
+ "fallback": "tmux_headless",
4749
+ "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.",
4750
+ }
4751
+ event_log.write("display.ghostty_workspace", agent_id=agent_id, **display)
4752
+ displays[agent_id] = display
4753
+ return displays
4754
+
4755
+
4756
+ def _ghostty_workspace_blocked(
4757
+ jobs: list[tuple[str, dict[str, Any]]],
4758
+ event_log: EventLog,
4759
+ reason: str,
4760
+ aggregator_session: str | None = None,
4761
+ linked_sessions: dict[str, str] | None = None,
4762
+ error: str | None = None,
4763
+ target: str | None = None,
4764
+ ) -> dict[str, dict[str, Any]]:
4765
+ displays: dict[str, dict[str, Any]] = {}
4766
+ for agent_id, _agent in jobs:
4767
+ linked_session = (linked_sessions or {}).get(agent_id)
4768
+ display = {
4769
+ "backend": "ghostty_workspace",
4770
+ "status": "blocked",
4771
+ "reason": reason,
4772
+ "error": error,
4773
+ "target": target or f"{agent_id}",
4774
+ "linked_session": linked_session,
4775
+ "aggregator_session": aggregator_session,
4776
+ "display_session": aggregator_session,
4777
+ "fallback": "tmux_headless",
4778
+ }
4779
+ event_log.write("display.ghostty_workspace_blocked", agent_id=agent_id, **display)
4780
+ displays[agent_id] = display
4781
+ return displays
4782
+
4783
+
4604
4784
  def _ghostty_display_session_name(session_name: str, window_name: str) -> str:
4605
4785
  raw = f"{session_name}:{window_name}"
4606
4786
  digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
@@ -4628,6 +4808,336 @@ def _prepare_ghostty_display_session(session_name: str, window_name: str, displa
4628
4808
  return {"ok": True, "display_session": display_session}
4629
4809
 
4630
4810
 
4811
+ def _ghostty_workspace_aggregator_name(session_name: str) -> str:
4812
+ raw = f"{session_name}:workspace"
4813
+ digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
4814
+ safe_session = re.sub(r"[^A-Za-z0-9_.-]", "_", session_name)[:80].strip("._-") or "team"
4815
+ return f"{safe_session}__display__workspace__{digest}"
4816
+
4817
+
4818
+ def _ghostty_workspace_window_name(index: int) -> str:
4819
+ return "overview" if index == 0 else f"overview-{index + 1}"
4820
+
4821
+
4822
+ def _ghostty_workspace_pane_command(linked_session: str) -> str:
4823
+ return f"TMUX= tmux attach-session -t {shlex.quote(linked_session)}"
4824
+
4825
+
4826
+ def _ghostty_workspace_pane_title(agent: dict[str, Any]) -> str:
4827
+ return f"team-agent:{agent['id']}:{agent.get('role', '')}"
4828
+
4829
+
4830
+ def _prepare_ghostty_workspace_linked_sessions(
4831
+ session_name: str,
4832
+ jobs: list[tuple[str, dict[str, Any]]],
4833
+ ) -> dict[str, dict[str, Any]]:
4834
+ def prepare(agent_id: str) -> dict[str, Any]:
4835
+ linked_session = _ghostty_display_session_name(session_name, agent_id)
4836
+ result = _prepare_ghostty_display_session(session_name, agent_id, linked_session)
4837
+ result["linked_session"] = linked_session
4838
+ return result
4839
+
4840
+ if len(jobs) == 1:
4841
+ agent_id, _agent = jobs[0]
4842
+ return {agent_id: prepare(agent_id)}
4843
+ results: dict[str, dict[str, Any]] = {}
4844
+ max_workers = min(4, len(jobs))
4845
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
4846
+ futures = {executor.submit(prepare, agent_id): agent_id for agent_id, _agent in jobs}
4847
+ for future in as_completed(futures):
4848
+ agent_id = futures[future]
4849
+ try:
4850
+ results[agent_id] = future.result()
4851
+ except Exception as exc:
4852
+ results[agent_id] = {
4853
+ "ok": False,
4854
+ "reason": "display_session_create_exception",
4855
+ "error": str(exc),
4856
+ "linked_session": _ghostty_display_session_name(session_name, agent_id),
4857
+ }
4858
+ return results
4859
+
4860
+
4861
+ def _prepare_ghostty_workspace_aggregator(
4862
+ aggregator_session: str,
4863
+ linked_jobs: list[tuple[str, dict[str, Any], str]],
4864
+ ) -> dict[str, Any]:
4865
+ if _tmux_session_exists(aggregator_session):
4866
+ proc = run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4867
+ if proc.returncode != 0:
4868
+ return {"ok": False, "reason": "display_session_cleanup_failed", "error": proc.stderr.strip()}
4869
+
4870
+ def fail(reason: str, proc: Any | None = None, target: str | None = None) -> dict[str, Any]:
4871
+ run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
4872
+ result = {"ok": False, "reason": reason}
4873
+ if proc is not None:
4874
+ result["error"] = proc.stderr.strip()
4875
+ if target:
4876
+ result["target"] = target
4877
+ return result
4878
+
4879
+ panes: list[dict[str, Any]] = []
4880
+ for window_index, start in enumerate(range(0, len(linked_jobs), GHOSTTY_WORKSPACE_PANES_PER_WINDOW)):
4881
+ window_name = _ghostty_workspace_window_name(window_index)
4882
+ window_jobs = linked_jobs[start : start + GHOSTTY_WORKSPACE_PANES_PER_WINDOW]
4883
+ first_agent_id, first_agent, first_linked_session = window_jobs[0]
4884
+ if window_index == 0:
4885
+ proc = run_cmd(
4886
+ [
4887
+ "tmux",
4888
+ "new-session",
4889
+ "-d",
4890
+ "-P",
4891
+ "-F",
4892
+ "#{pane_id}",
4893
+ "-s",
4894
+ aggregator_session,
4895
+ "-n",
4896
+ window_name,
4897
+ _ghostty_workspace_pane_command(first_linked_session),
4898
+ ],
4899
+ timeout=10,
4900
+ )
4901
+ if proc.returncode != 0:
4902
+ return {"ok": False, "reason": "display_session_create_failed", "error": proc.stderr.strip()}
4903
+ else:
4904
+ proc = run_cmd(
4905
+ [
4906
+ "tmux",
4907
+ "new-window",
4908
+ "-t",
4909
+ aggregator_session,
4910
+ "-n",
4911
+ window_name,
4912
+ "-P",
4913
+ "-F",
4914
+ "#{pane_id}",
4915
+ _ghostty_workspace_pane_command(first_linked_session),
4916
+ ],
4917
+ timeout=10,
4918
+ )
4919
+ if proc.returncode != 0:
4920
+ return fail("display_session_window_create_failed", proc, first_linked_session)
4921
+ first_pane_id = _tmux_stdout_last_line(proc.stdout) or f"{aggregator_session}:{window_name}.0"
4922
+ first_title = _ghostty_workspace_pane_title(first_agent)
4923
+ title_result = _set_ghostty_workspace_pane_title(first_pane_id, first_title)
4924
+ if not title_result["ok"]:
4925
+ return fail(title_result["reason"], target=first_pane_id)
4926
+ panes.append(
4927
+ {
4928
+ "agent_id": first_agent_id,
4929
+ "pane_id": first_pane_id,
4930
+ "title": first_title,
4931
+ "linked_session": first_linked_session,
4932
+ "window_name": window_name,
4933
+ }
4934
+ )
4935
+
4936
+ proc = run_cmd(["tmux", "set-window-option", "-t", f"{aggregator_session}:{window_name}", "remain-on-exit", "on"], timeout=10)
4937
+ if proc.returncode != 0:
4938
+ return fail("display_session_remain_on_exit_failed", proc)
4939
+
4940
+ for index, (agent_id, agent, linked_session) in enumerate(window_jobs[1:], start=1):
4941
+ proc = run_cmd(
4942
+ [
4943
+ "tmux",
4944
+ "split-window",
4945
+ "-t",
4946
+ f"{aggregator_session}:{window_name}",
4947
+ "-h",
4948
+ "-P",
4949
+ "-F",
4950
+ "#{pane_id}",
4951
+ _ghostty_workspace_pane_command(linked_session),
4952
+ ],
4953
+ timeout=10,
4954
+ )
4955
+ if proc.returncode != 0:
4956
+ return fail("display_session_split_failed", proc, linked_session)
4957
+ pane_id = _tmux_stdout_last_line(proc.stdout) or f"{aggregator_session}:{window_name}.{index}"
4958
+ title = _ghostty_workspace_pane_title(agent)
4959
+ title_result = _set_ghostty_workspace_pane_title(pane_id, title)
4960
+ if not title_result["ok"]:
4961
+ return fail(title_result["reason"], target=pane_id)
4962
+ panes.append(
4963
+ {
4964
+ "agent_id": agent_id,
4965
+ "pane_id": pane_id,
4966
+ "title": title,
4967
+ "linked_session": linked_session,
4968
+ "window_name": window_name,
4969
+ }
4970
+ )
4971
+
4972
+ proc = run_cmd(["tmux", "select-layout", "-t", f"{aggregator_session}:{window_name}", "even-horizontal"], timeout=10)
4973
+ if proc.returncode != 0:
4974
+ return fail("display_session_layout_failed", proc)
4975
+
4976
+ proc = run_cmd(["tmux", "set-option", "-t", aggregator_session, "mouse", "on"], timeout=10)
4977
+ if proc.returncode != 0:
4978
+ return fail("display_session_mouse_failed", proc)
4979
+ run_cmd(["tmux", "select-window", "-t", f"{aggregator_session}:{_ghostty_workspace_window_name(0)}"], timeout=10)
4980
+ return {"ok": True, "aggregator_session": aggregator_session, "panes": panes}
4981
+
4982
+
4983
+ def _set_ghostty_workspace_pane_title(pane_id: str, title: str) -> dict[str, Any]:
4984
+ proc = run_cmd(["tmux", "select-pane", "-t", pane_id, "-T", title], timeout=10)
4985
+ if proc.returncode != 0:
4986
+ return {"ok": False, "reason": "display_session_pane_title_failed", "error": proc.stderr.strip()}
4987
+ return {"ok": True}
4988
+
4989
+
4990
+ def _tmux_stdout_last_line(stdout: str) -> str | None:
4991
+ lines = [line.strip() for line in stdout.splitlines() if line.strip()]
4992
+ return lines[-1] if lines else None
4993
+
4994
+
4995
+ def _open_ghostty_workspace_agent_display(
4996
+ session_name: str,
4997
+ agent_id: str,
4998
+ agent: dict[str, Any],
4999
+ previous_display: dict[str, Any],
5000
+ event_log: EventLog,
5001
+ ) -> dict[str, Any]:
5002
+ if not _ghostty_app_exists():
5003
+ return _ghostty_workspace_blocked(
5004
+ [(agent_id, agent)],
5005
+ event_log,
5006
+ "ghostty_app_missing",
5007
+ aggregator_session=_ghostty_workspace_aggregator_name(session_name),
5008
+ linked_sessions={agent_id: _ghostty_display_session_name(session_name, agent_id)},
5009
+ target=f"{session_name}:{agent_id}",
5010
+ )[agent_id]
5011
+ aggregator_session = str(
5012
+ previous_display.get("aggregator_session")
5013
+ or previous_display.get("display_session")
5014
+ or _ghostty_workspace_aggregator_name(session_name)
5015
+ )
5016
+ linked_session = _ghostty_display_session_name(session_name, agent_id)
5017
+ prepared = _prepare_ghostty_display_session(session_name, agent_id, linked_session)
5018
+ if not prepared["ok"]:
5019
+ return _ghostty_workspace_blocked(
5020
+ [(agent_id, agent)],
5021
+ event_log,
5022
+ prepared["reason"],
5023
+ aggregator_session=aggregator_session,
5024
+ linked_sessions={agent_id: linked_session},
5025
+ error=prepared.get("error"),
5026
+ target=f"{session_name}:{agent_id}",
5027
+ )[agent_id]
5028
+ if not _tmux_session_exists(aggregator_session):
5029
+ return _ghostty_workspace_partial_update_display(
5030
+ session_name,
5031
+ agent_id,
5032
+ agent,
5033
+ event_log,
5034
+ reason="aggregator_session_missing",
5035
+ note="pane refresh requires full team restart",
5036
+ )
5037
+
5038
+ pane_title = _ghostty_workspace_pane_title(agent)
5039
+ command = _ghostty_workspace_pane_command(linked_session)
5040
+ pane_id = str(previous_display.get("pane_id") or "")
5041
+ workspace_window = str(previous_display.get("workspace_window") or _ghostty_workspace_window_name(0))
5042
+ refreshed = False
5043
+ if pane_id:
5044
+ proc = run_cmd(["tmux", "respawn-pane", "-k", "-t", pane_id, command], timeout=10)
5045
+ refreshed = proc.returncode == 0
5046
+ if not refreshed:
5047
+ proc = run_cmd(
5048
+ [
5049
+ "tmux",
5050
+ "split-window",
5051
+ "-t",
5052
+ f"{aggregator_session}:{workspace_window}",
5053
+ "-h",
5054
+ "-P",
5055
+ "-F",
5056
+ "#{pane_id}",
5057
+ command,
5058
+ ],
5059
+ timeout=10,
5060
+ )
5061
+ if proc.returncode != 0:
5062
+ return _ghostty_workspace_partial_update_display(
5063
+ session_name,
5064
+ agent_id,
5065
+ agent,
5066
+ event_log,
5067
+ reason="aggregator_pane_refresh_failed",
5068
+ note=proc.stderr.strip() or "pane refresh requires full team restart",
5069
+ )
5070
+ pane_id = _tmux_stdout_last_line(proc.stdout) or pane_id
5071
+ title_result = _set_ghostty_workspace_pane_title(pane_id, pane_title)
5072
+ if not title_result["ok"]:
5073
+ return _ghostty_workspace_partial_update_display(
5074
+ session_name,
5075
+ agent_id,
5076
+ agent,
5077
+ event_log,
5078
+ reason=title_result["reason"],
5079
+ note=title_result.get("error") or "pane refresh requires full team restart",
5080
+ )
5081
+ run_cmd(["tmux", "select-layout", "-t", f"{aggregator_session}:{workspace_window}", "even-horizontal"], timeout=10)
5082
+ title = str(previous_display.get("title") or f"team-agent:{session_name}:workspace")
5083
+ pids = [int(pid) for pid in previous_display.get("pids", []) if str(pid).isdigit()]
5084
+ display = {
5085
+ "backend": "ghostty_workspace",
5086
+ "status": "opened",
5087
+ "title": title,
5088
+ "pane_title": pane_title,
5089
+ "target": f"{session_name}:{agent_id}",
5090
+ "linked_session": linked_session,
5091
+ "aggregator_session": aggregator_session,
5092
+ "display_session": aggregator_session,
5093
+ "workspace_window": workspace_window,
5094
+ "pane_id": pane_id,
5095
+ "pid": pids[0] if pids else None,
5096
+ "pids": pids,
5097
+ "tty": None,
5098
+ "fallback": "tmux_headless",
5099
+ "note": "Refreshed this worker's Ghostty workspace pane by respawning it against a distinct linked session.",
5100
+ }
5101
+ event_log.write("display.ghostty_workspace", agent_id=agent_id, **display)
5102
+ return display
5103
+
5104
+
5105
+ def _ghostty_workspace_partial_update_display(
5106
+ session_name: str,
5107
+ agent_id: str,
5108
+ agent: dict[str, Any],
5109
+ event_log: EventLog,
5110
+ reason: str = "partial_update_requires_team_restart",
5111
+ note: str = "pane refresh requires full team restart",
5112
+ ) -> dict[str, Any]:
5113
+ aggregator_session = _ghostty_workspace_aggregator_name(session_name)
5114
+ display = {
5115
+ "backend": "ghostty_workspace",
5116
+ "status": "blocked",
5117
+ "reason": reason,
5118
+ "target": f"{session_name}:{agent_id}",
5119
+ "linked_session": _ghostty_display_session_name(session_name, agent_id),
5120
+ "aggregator_session": aggregator_session,
5121
+ "display_session": aggregator_session,
5122
+ "pane_title": _ghostty_workspace_pane_title(agent),
5123
+ "fallback": "tmux_headless",
5124
+ "note": note,
5125
+ "action": "restart the team to rebuild the Ghostty workspace layout",
5126
+ }
5127
+ event_log.write("display.ghostty_workspace_partial_update", agent_id=agent_id, **display)
5128
+ return display
5129
+
5130
+
5131
+ def _kill_ghostty_workspace_linked_sessions(linked_sessions: list[str]) -> list[str]:
5132
+ killed: list[str] = []
5133
+ for linked_session in dict.fromkeys(linked_sessions):
5134
+ if _tmux_session_exists(linked_session):
5135
+ proc = run_cmd(["tmux", "kill-session", "-t", linked_session], timeout=10)
5136
+ if proc.returncode == 0:
5137
+ killed.append(linked_session)
5138
+ return killed
5139
+
5140
+
4631
5141
  def _ghostty_attach_args(display_session: str, title: str) -> list[str]:
4632
5142
  return [
4633
5143
  "open",
@@ -4643,7 +5153,11 @@ def _ghostty_attach_args(display_session: str, title: str) -> list[str]:
4643
5153
  ]
4644
5154
 
4645
5155
 
4646
- def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log: EventLog) -> None:
5156
+ def _close_ghostty_display(
5157
+ agent_id: str,
5158
+ agent_state: dict[str, Any],
5159
+ event_log: EventLog,
5160
+ ) -> None:
4647
5161
  display = agent_state.get("display") or {}
4648
5162
  if display.get("backend") != "ghostty_window":
4649
5163
  return
@@ -4651,9 +5165,7 @@ def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log
4651
5165
  pids = [str(pid) for pid in display.get("pids", []) if str(pid).isdigit()]
4652
5166
  title = display.get("title")
4653
5167
  if not pids and title:
4654
- pgrep = run_cmd(["pgrep", "-f", f"--title={title}"], timeout=5)
4655
- if pgrep.returncode == 0:
4656
- pids = [pid for pid in pgrep.stdout.split() if pid.isdigit()]
5168
+ pids = [str(pid) for pid in _ghostty_pids_by_title(str(title))]
4657
5169
  killed: list[str] = []
4658
5170
  for pid in pids:
4659
5171
  proc = run_cmd(["kill", pid], timeout=5)
@@ -4674,6 +5186,66 @@ def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log
4674
5186
  )
4675
5187
 
4676
5188
 
5189
+ def _close_ghostty_workspace(state: dict[str, Any], event_log: EventLog) -> None:
5190
+ displays = [
5191
+ (agent_id, agent_state.get("display") or {})
5192
+ for agent_id, agent_state in state.get("agents", {}).items()
5193
+ if (agent_state.get("display") or {}).get("backend") == "ghostty_workspace"
5194
+ ]
5195
+ if not displays:
5196
+ return
5197
+ aggregator_session = next(
5198
+ (
5199
+ str(display.get("aggregator_session") or display.get("display_session"))
5200
+ for _agent_id, display in displays
5201
+ if display.get("aggregator_session") or display.get("display_session")
5202
+ ),
5203
+ None,
5204
+ )
5205
+ title = next((str(display.get("title")) for _agent_id, display in displays if display.get("title")), None)
5206
+ pids = {
5207
+ str(pid)
5208
+ for _agent_id, display in displays
5209
+ for pid in display.get("pids", [])
5210
+ if str(pid).isdigit()
5211
+ }
5212
+ if not pids and title:
5213
+ pids = {str(pid) for pid in _ghostty_pids_by_title(str(title))}
5214
+
5215
+ aggregator_closed = False
5216
+ if aggregator_session and _tmux_session_exists(aggregator_session):
5217
+ proc = run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
5218
+ if proc.returncode == 0:
5219
+ aggregator_closed = True
5220
+ else:
5221
+ event_log.write(
5222
+ "display.ghostty_workspace_close_failed",
5223
+ aggregator_session=aggregator_session,
5224
+ error=proc.stderr.strip(),
5225
+ )
5226
+
5227
+ linked_sessions = [
5228
+ str(display.get("linked_session"))
5229
+ for _agent_id, display in displays
5230
+ if display.get("linked_session")
5231
+ ]
5232
+ linked_closed = _kill_ghostty_workspace_linked_sessions(linked_sessions)
5233
+
5234
+ killed: list[str] = []
5235
+ for pid in sorted(pids):
5236
+ proc = run_cmd(["kill", pid], timeout=5)
5237
+ if proc.returncode == 0:
5238
+ killed.append(pid)
5239
+ event_log.write(
5240
+ "display.ghostty_workspace_closed",
5241
+ pids=killed,
5242
+ title=title,
5243
+ aggregator_session=aggregator_session,
5244
+ linked_sessions=linked_closed,
5245
+ aggregator_closed=aggregator_closed,
5246
+ )
5247
+
5248
+
4677
5249
  def get_adapter_or_raise(name: str) -> str:
4678
5250
  if name == "tmux" and not shutil_which("tmux"):
4679
5251
  raise RuntimeError("tmux is not installed; install tmux 3.3+ before launch")