@team-agent/installer 0.1.7 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -29,7 +29,6 @@
29
29
  "npm",
30
30
  "scripts",
31
31
  "src",
32
- "tests",
33
32
  "skills",
34
33
  "templates",
35
34
  "examples",
@@ -63,7 +63,7 @@
63
63
  "additionalProperties": false,
64
64
  "properties": {
65
65
  "backend": { "enum": ["tmux", "pty"] },
66
- "display_backend": { "enum": ["none", "tmux_attach", "iterm", "ghostty"] },
66
+ "display_backend": { "enum": ["none", "tmux_attach", "iterm", "ghostty", "ghostty_window", "ghostty_workspace"] },
67
67
  "session_name": { "type": "string", "minLength": 1 },
68
68
  "auto_launch": { "type": "boolean" },
69
69
  "require_user_approval_before_launch": { "type": "boolean" },
@@ -54,6 +54,7 @@ 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"}
57
58
  PEEK_MAX_LINES = 80
58
59
  PEEK_SEARCH_SCAN_LINES = 300
59
60
  PEEK_MAX_MATCHES = 5
@@ -456,10 +457,16 @@ def launch(
456
457
  timeout_s=1.5,
457
458
  exclude_session_ids=known_session_ids,
458
459
  )
459
- if state.get("display_backend") in {"ghostty", "ghostty_window"}:
460
+ if state.get("display_backend") in GHOSTTY_DISPLAY_BACKENDS:
460
461
  display_jobs.append((agent["id"], agent))
461
462
  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():
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():
463
470
  if agent_id in state["agents"]:
464
471
  state["agents"][agent_id]["display"] = display
465
472
  save_runtime_state(workspace, state)
@@ -1753,6 +1760,7 @@ def shutdown(workspace: Path, keep_logs: bool = True) -> dict[str, Any]:
1753
1760
  if proc.returncode == 0:
1754
1761
  log_path.write_text(proc.stdout, encoding="utf-8")
1755
1762
  captured.append(str(log_path))
1763
+ _close_ghostty_workspace(state, event_log)
1756
1764
  for agent_id, agent_state in state.get("agents", {}).items():
1757
1765
  _close_ghostty_display(agent_id, agent_state, event_log)
1758
1766
  closed_displays.add(agent_id)
@@ -1762,6 +1770,7 @@ def shutdown(workspace: Path, keep_logs: bool = True) -> dict[str, Any]:
1762
1770
  event_log.write("shutdown.kill_session", session=session_name, keep_logs=keep_logs, captured=captured)
1763
1771
  else:
1764
1772
  event_log.write("shutdown.idempotent", session=session_name, reason="session missing")
1773
+ _close_ghostty_workspace(state, event_log)
1765
1774
  for agent_id, agent_state in state.get("agents", {}).items():
1766
1775
  if agent_id not in closed_displays:
1767
1776
  _close_ghostty_display(agent_id, agent_state, event_log)
@@ -1995,7 +2004,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
1995
2004
  timeout_s=1.5,
1996
2005
  exclude_session_ids=known_session_ids,
1997
2006
  )
1998
- if display_backend in {"ghostty", "ghostty_window"}:
2007
+ if display_backend in GHOSTTY_DISPLAY_BACKENDS:
1999
2008
  display_jobs.append((agent["id"], agent))
2000
2009
  new_agents[agent["id"]] = agent_state
2001
2010
  restarted.append(
@@ -2006,7 +2015,7 @@ def restart(workspace: Path, allow_fresh: bool = False, team: str | None = None)
2006
2015
  "display_target": None,
2007
2016
  }
2008
2017
  )
2009
- 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)
2010
2019
  for agent_id, display in display_results.items():
2011
2020
  if agent_id in new_agents:
2012
2021
  new_agents[agent_id]["display"] = display
@@ -2072,6 +2081,10 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
2072
2081
  display = agent_state.get("display") or {}
2073
2082
  if display.get("status") != "opened":
2074
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)
2075
2088
  state["agents"][agent_id] = agent_state
2076
2089
  save_runtime_state(workspace, state)
2077
2090
  write_team_state(workspace, spec, state)
@@ -2248,6 +2261,14 @@ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_disp
2248
2261
  _capture_agent_session(workspace, agent_id, agent_state, event_log, timeout_s=1.5, exclude_session_ids=known_session_ids)
2249
2262
  if open_display and state.get("display_backend") in {"ghostty", "ghostty_window"}:
2250
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
+ )
2251
2272
  state["agents"][agent_id] = agent_state
2252
2273
  save_runtime_state(workspace, state)
2253
2274
  store = MessageStore(workspace)
@@ -3030,7 +3051,7 @@ def preflight(team_dir: Path) -> dict[str, Any]:
3030
3051
  ok = ok and bool(tmux_path)
3031
3052
  ghostty = _ghostty_command()
3032
3053
  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"}:
3054
+ if spec and spec.get("runtime", {}).get("display_backend") in GHOSTTY_DISPLAY_BACKENDS:
3034
3055
  ghostty_check["required"] = True
3035
3056
  ok = ok and bool(ghostty)
3036
3057
  checks.append(ghostty_check)
@@ -4508,14 +4529,34 @@ def _ghostty_command() -> str | None:
4508
4529
  )
4509
4530
 
4510
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
+
4511
4549
  def _open_worker_displays(
4512
4550
  workspace: Path,
4513
4551
  session_name: str,
4514
4552
  jobs: list[tuple[str, dict[str, Any]]],
4515
4553
  event_log: EventLog,
4554
+ display_backend: str = "ghostty_window",
4516
4555
  ) -> dict[str, dict[str, Any]]:
4517
4556
  if not jobs:
4518
4557
  return {}
4558
+ if display_backend == "ghostty_workspace":
4559
+ return _open_ghostty_workspace(workspace, session_name, jobs, event_log)
4519
4560
  if len(jobs) == 1:
4520
4561
  agent_id, agent = jobs[0]
4521
4562
  return {agent_id: _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)}
@@ -4550,7 +4591,7 @@ def _open_ghostty_worker_window(
4550
4591
  agent: dict[str, Any],
4551
4592
  event_log: EventLog,
4552
4593
  ) -> dict[str, Any]:
4553
- if not Path("/Applications/Ghostty.app").exists():
4594
+ if not _ghostty_app_exists():
4554
4595
  blocker = {
4555
4596
  "backend": "ghostty_window",
4556
4597
  "status": "blocked",
@@ -4592,15 +4633,129 @@ def _open_ghostty_worker_window(
4592
4633
  if proc.returncode != 0:
4593
4634
  display["reason"] = proc.stderr.strip() or proc.stdout.strip() or "open Ghostty.app failed"
4594
4635
  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
4636
+ display["pids"] = _ghostty_pids_by_title(title, wait_s=3.0)
4637
+ display["pid"] = display["pids"][0] if display["pids"] else None
4600
4638
  event_log.write("display.ghostty_window", agent_id=agent["id"], **display)
4601
4639
  return display
4602
4640
 
4603
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
+
4604
4759
  def _ghostty_display_session_name(session_name: str, window_name: str) -> str:
4605
4760
  raw = f"{session_name}:{window_name}"
4606
4761
  digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
@@ -4628,6 +4783,284 @@ def _prepare_ghostty_display_session(session_name: str, window_name: str, displa
4628
4783
  return {"ok": True, "display_session": display_session}
4629
4784
 
4630
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
+
4631
5064
  def _ghostty_attach_args(display_session: str, title: str) -> list[str]:
4632
5065
  return [
4633
5066
  "open",
@@ -4643,7 +5076,11 @@ def _ghostty_attach_args(display_session: str, title: str) -> list[str]:
4643
5076
  ]
4644
5077
 
4645
5078
 
4646
- 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:
4647
5084
  display = agent_state.get("display") or {}
4648
5085
  if display.get("backend") != "ghostty_window":
4649
5086
  return
@@ -4651,9 +5088,7 @@ def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log
4651
5088
  pids = [str(pid) for pid in display.get("pids", []) if str(pid).isdigit()]
4652
5089
  title = display.get("title")
4653
5090
  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()]
5091
+ pids = [str(pid) for pid in _ghostty_pids_by_title(str(title))]
4657
5092
  killed: list[str] = []
4658
5093
  for pid in pids:
4659
5094
  proc = run_cmd(["kill", pid], timeout=5)
@@ -4674,6 +5109,66 @@ def _close_ghostty_display(agent_id: str, agent_state: dict[str, Any], event_log
4674
5109
  )
4675
5110
 
4676
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
+
4677
5172
  def get_adapter_or_raise(name: str) -> str:
4678
5173
  if name == "tmux" and not shutil_which("tmux"):
4679
5174
  raise RuntimeError("tmux is not installed; install tmux 3.3+ before launch")
@@ -196,7 +196,7 @@ def _check_runtime(runtime: Any, errors: list[str]) -> None:
196
196
  return
197
197
  if runtime.get("backend") not in {"tmux", "pty"}:
198
198
  errors.append("/runtime/backend: invalid backend")
199
- if runtime.get("display_backend") not in {"none", "tmux_attach", "iterm", "ghostty", "ghostty_window"}:
199
+ if runtime.get("display_backend") not in {"none", "tmux_attach", "iterm", "ghostty", "ghostty_window", "ghostty_workspace"}:
200
200
  errors.append("/runtime/display_backend: invalid display backend")
201
201
  if "dangerous_auto_approve" in runtime and not isinstance(runtime["dangerous_auto_approve"], bool):
202
202
  errors.append("/runtime/dangerous_auto_approve: must be a boolean")