@team-agent/installer 0.1.11 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/crates/team-agent-core/src/lib.rs +50 -5
  2. package/package.json +1 -1
  3. package/schemas/team.schema.json +1 -0
  4. package/src/team_agent/approvals/__init__.py +65 -0
  5. package/src/team_agent/approvals/constants.py +6 -0
  6. package/src/team_agent/approvals/parsing.py +176 -0
  7. package/src/team_agent/approvals/runtime_prompts.py +171 -0
  8. package/src/team_agent/approvals/status.py +165 -0
  9. package/src/team_agent/cli/__init__.py +137 -0
  10. package/src/team_agent/cli/commands.py +339 -0
  11. package/src/team_agent/cli/e2e.py +202 -0
  12. package/src/team_agent/cli/helpers.py +137 -0
  13. package/src/team_agent/cli/parser.py +477 -0
  14. package/src/team_agent/compiler.py +98 -33
  15. package/src/team_agent/coordinator/__init__.py +53 -0
  16. package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
  17. package/src/team_agent/coordinator/lifecycle.py +334 -0
  18. package/src/team_agent/coordinator/metadata.py +61 -0
  19. package/src/team_agent/coordinator/paths.py +17 -0
  20. package/src/team_agent/diagnose/__init__.py +48 -0
  21. package/src/team_agent/diagnose/checks.py +101 -0
  22. package/src/team_agent/diagnose/health.py +241 -0
  23. package/src/team_agent/diagnose/preflight.py +194 -0
  24. package/src/team_agent/diagnose/quick_start.py +233 -0
  25. package/src/team_agent/display/__init__.py +61 -0
  26. package/src/team_agent/display/close.py +147 -0
  27. package/src/team_agent/display/ghostty.py +77 -0
  28. package/src/team_agent/display/worker_window.py +110 -0
  29. package/src/team_agent/display/workspace.py +473 -0
  30. package/src/team_agent/launch/__init__.py +41 -0
  31. package/src/team_agent/launch/bootstrap.py +85 -0
  32. package/src/team_agent/launch/config.py +106 -0
  33. package/src/team_agent/launch/core.py +291 -0
  34. package/src/team_agent/launch/requirements.py +57 -0
  35. package/src/team_agent/leader/__init__.py +320 -0
  36. package/src/team_agent/lifecycle/__init__.py +5 -0
  37. package/src/team_agent/lifecycle/agents.py +226 -0
  38. package/src/team_agent/lifecycle/operations.py +321 -0
  39. package/src/team_agent/lifecycle/paste_buffer_hygiene.py +39 -0
  40. package/src/team_agent/lifecycle/start.py +363 -0
  41. package/src/team_agent/mcp_server/__init__.py +42 -0
  42. package/src/team_agent/mcp_server/__main__.py +7 -0
  43. package/src/team_agent/mcp_server/contracts.py +148 -0
  44. package/src/team_agent/mcp_server/normalize.py +257 -0
  45. package/src/team_agent/mcp_server/server.py +150 -0
  46. package/src/team_agent/mcp_server/tools.py +205 -0
  47. package/src/team_agent/message_store/__init__.py +23 -0
  48. package/src/team_agent/message_store/agent_health.py +109 -0
  49. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  50. package/src/team_agent/message_store/result_watchers.py +102 -0
  51. package/src/team_agent/message_store/schema.py +266 -0
  52. package/src/team_agent/messaging/__init__.py +1 -0
  53. package/src/team_agent/messaging/activity_detector.py +190 -0
  54. package/src/team_agent/messaging/delivery.py +138 -0
  55. package/src/team_agent/messaging/deps.py +263 -0
  56. package/src/team_agent/messaging/idle_alerts.py +323 -0
  57. package/src/team_agent/messaging/internal_delivery.py +46 -0
  58. package/src/team_agent/messaging/leader.py +317 -0
  59. package/src/team_agent/messaging/leader_panes.py +343 -0
  60. package/src/team_agent/messaging/owner_bypass.py +29 -0
  61. package/src/team_agent/messaging/result_delivery.py +300 -0
  62. package/src/team_agent/messaging/results.py +456 -0
  63. package/src/team_agent/messaging/scheduler.py +428 -0
  64. package/src/team_agent/messaging/send.py +500 -0
  65. package/src/team_agent/messaging/session_drift.py +94 -0
  66. package/src/team_agent/messaging/tmux_io.py +337 -0
  67. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  68. package/src/team_agent/orchestrator/__init__.py +376 -0
  69. package/src/team_agent/orchestrator/plan.py +122 -0
  70. package/src/team_agent/orchestrator/state.py +128 -0
  71. package/src/team_agent/profiles/__init__.py +82 -0
  72. package/src/team_agent/profiles/constants.py +19 -0
  73. package/src/team_agent/profiles/core.py +407 -0
  74. package/src/team_agent/profiles/helpers.py +69 -0
  75. package/src/team_agent/profiles/provider_env.py +188 -0
  76. package/src/team_agent/profiles/smoke.py +201 -0
  77. package/src/team_agent/provider_cli/__init__.py +43 -0
  78. package/src/team_agent/provider_cli/adapter.py +167 -0
  79. package/src/team_agent/provider_cli/base.py +48 -0
  80. package/src/team_agent/provider_cli/claude.py +457 -0
  81. package/src/team_agent/provider_cli/codex.py +319 -0
  82. package/src/team_agent/provider_cli/copilot.py +8 -0
  83. package/src/team_agent/provider_cli/fake.py +39 -0
  84. package/src/team_agent/provider_cli/gemini.py +95 -0
  85. package/src/team_agent/provider_cli/opencode.py +8 -0
  86. package/src/team_agent/provider_cli/prompt.py +62 -0
  87. package/src/team_agent/provider_cli/registry.py +18 -0
  88. package/src/team_agent/provider_cli/unsupported.py +32 -0
  89. package/src/team_agent/providers.py +67 -949
  90. package/src/team_agent/quality_gates.py +104 -0
  91. package/src/team_agent/restart/__init__.py +34 -0
  92. package/src/team_agent/restart/orchestration.py +328 -0
  93. package/src/team_agent/restart/selection.py +89 -0
  94. package/src/team_agent/restart/snapshot.py +70 -0
  95. package/src/team_agent/runtime.py +809 -5892
  96. package/src/team_agent/rust_core.py +22 -5
  97. package/src/team_agent/sessions/__init__.py +25 -0
  98. package/src/team_agent/sessions/capture.py +93 -0
  99. package/src/team_agent/sessions/inventory.py +44 -0
  100. package/src/team_agent/sessions/resume.py +135 -0
  101. package/src/team_agent/spec.py +3 -1
  102. package/src/team_agent/state.py +218 -4
  103. package/src/team_agent/status/__init__.py +63 -0
  104. package/src/team_agent/status/approvals.py +52 -0
  105. package/src/team_agent/status/compact.py +158 -0
  106. package/src/team_agent/status/constants.py +18 -0
  107. package/src/team_agent/status/inbox.py +28 -0
  108. package/src/team_agent/status/peek.py +117 -0
  109. package/src/team_agent/status/queries.py +168 -0
  110. package/src/team_agent/terminal.py +57 -0
  111. package/src/team_agent/cli.py +0 -858
  112. package/src/team_agent/mcp_server.py +0 -579
  113. package/src/team_agent/profiles.py +0 -882
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from team_agent.diagnose.preflight import ensure_profiles_for_roles, preflight
10
+ from team_agent.events import EventLog
11
+ from team_agent.message_store import MessageStore
12
+ from team_agent.paths import logs_dir, team_workspace
13
+ from team_agent.spec import load_spec
14
+ from team_agent.state import load_runtime_state, save_runtime_state, write_team_state
15
+ from team_agent.task_graph import TASK_STATUSES
16
+
17
+
18
+ def quick_start(
19
+ agents_dir: Path,
20
+ name: str | None = None,
21
+ yes: bool = False,
22
+ fresh: bool = False,
23
+ team_id: str | None = None,
24
+ ) -> dict[str, Any]:
25
+ from team_agent.runtime import (
26
+ RuntimeError,
27
+ _compile_team_dir_spec,
28
+ _quick_start_existing_context,
29
+ ensure_workspace_dirs,
30
+ launch,
31
+ start_coordinator,
32
+ )
33
+
34
+ team_dir = prepare_quick_start_team(agents_dir.resolve(), Path.cwd().resolve(), name, team_id=team_id)
35
+ workspace = team_workspace(team_dir)
36
+ ensure_workspace_dirs(workspace)
37
+ ensure_profiles_for_roles(team_dir)
38
+ compiled = _compile_team_dir_spec(team_dir, workspace)
39
+ spec_path = team_dir / "team.spec.yaml"
40
+ existing = _quick_start_existing_context(workspace, compiled["spec"]["runtime"]["session_name"])
41
+ if existing and not fresh:
42
+ return {
43
+ "ok": False,
44
+ "step": "existing_runtime_state",
45
+ "summary": (
46
+ "quick-start would start fresh workers from role docs for an existing team. "
47
+ "Use restart to continue the previous worker context, or pass --fresh to intentionally start new workers."
48
+ ),
49
+ "team": existing.get("team_name"),
50
+ "session_name": existing.get("session_name"),
51
+ "state_path": existing.get("state_path"),
52
+ "next_actions": [
53
+ f"team-agent restart {workspace} --team {existing.get('session_name')}",
54
+ f"team-agent quick-start {team_dir} --fresh",
55
+ ],
56
+ }
57
+ preflight_result = preflight(team_dir)
58
+ if not preflight_result.get("ok"):
59
+ return {
60
+ "ok": False,
61
+ "step": "preflight",
62
+ "summary": preflight_result.get("summary"),
63
+ "details_log": preflight_result.get("details_log"),
64
+ "blockers": preflight_result.get("blockers", []),
65
+ "next_actions": preflight_result.get("next_actions", []),
66
+ "checks": preflight_result.get("checks", []),
67
+ }
68
+ dangerous = bool(compiled["spec"].get("runtime", {}).get("dangerous_auto_approve"))
69
+ if dangerous and not yes:
70
+ raise RuntimeError("quick-start requires --yes when dangerous_auto_approve is true")
71
+ launched = launch(spec_path, auto_approve=True, skip_profile_smoke=True)
72
+ from team_agent.leader import autobind_leader_receiver_from_env
73
+ leader_provider = str(compiled["spec"].get("leader", {}).get("provider") or "codex")
74
+ autobind_leader_receiver_from_env(workspace, leader_provider, source="quick_start")
75
+ coordinator = start_coordinator(workspace)
76
+ ready = wait_ready(workspace, timeout=120)
77
+ summary = (
78
+ f"team {compiled['spec']['team']['name']} ready: "
79
+ f"{len(launched.get('agents', []))} agent"
80
+ f"{'' if len(launched.get('agents', [])) == 1 else 's'} "
81
+ f"in session {launched.get('session_name')} (coordinator pid {coordinator.get('pid')})"
82
+ )
83
+ ready_signal = (
84
+ "quick-start completed; workers are ready. "
85
+ "Do not wait, sleep, or poll status after this success line unless diagnosing a failure."
86
+ )
87
+ details_log = logs_dir(workspace) / f"quick-start-{int(time.time())}.json"
88
+ details_log.write_text(
89
+ json.dumps(
90
+ {
91
+ "team_dir": str(team_dir),
92
+ "preflight": preflight_result,
93
+ "compile": compiled,
94
+ "launch": launched,
95
+ "ready": ready,
96
+ "coordinator": coordinator,
97
+ },
98
+ indent=2,
99
+ ensure_ascii=False,
100
+ ),
101
+ encoding="utf-8",
102
+ )
103
+ return {
104
+ "ok": bool(launched.get("ok") and ready.get("ok") and coordinator.get("ok")),
105
+ "summary": summary,
106
+ "ready_signal": ready_signal,
107
+ "next_actions": ["Dispatch work with team-agent send, or return control to the user."],
108
+ "team_dir": str(team_dir),
109
+ "spec": str(spec_path),
110
+ "session_name": launched.get("session_name"),
111
+ "coordinator": coordinator,
112
+ "details_log": str(details_log),
113
+ }
114
+
115
+
116
+ def prepare_quick_start_team(agents_dir: Path, workspace: Path, name: str | None, team_id: str | None = None) -> Path:
117
+ from team_agent.runtime import RuntimeError, _safe_snapshot_name
118
+
119
+ if (agents_dir / "TEAM.md").exists() and (agents_dir / "agents").is_dir():
120
+ return agents_dir
121
+ team_source = agents_dir / "TEAM.md"
122
+ role_docs = [path for path in sorted(agents_dir.glob("*.md")) if path.name != "TEAM.md"] if agents_dir.is_dir() else []
123
+ if not role_docs:
124
+ raise RuntimeError(f"{agents_dir}: expected .team/current or a directory of role .md files")
125
+ team_dir = workspace / ".team" / (_safe_snapshot_name(team_id) if team_id else "current")
126
+ target_agents = team_dir / "agents"
127
+ target_profiles = team_dir / "profiles"
128
+ target_agents.mkdir(parents=True, exist_ok=True)
129
+ target_profiles.mkdir(parents=True, exist_ok=True)
130
+ for role_doc in role_docs:
131
+ shutil.copy2(role_doc, target_agents / role_doc.name)
132
+ team_doc = team_dir / "TEAM.md"
133
+ if team_source.exists():
134
+ shutil.copy2(team_source, team_doc)
135
+ if name:
136
+ EventLog(workspace).write("quick_start.name_ignored_existing_team_doc", name=name, team_doc=str(team_doc))
137
+ elif not team_doc.exists():
138
+ team_name = name or agents_dir.name.replace(" ", "-") or "team-agent-team"
139
+ team_doc.write_text(
140
+ f"---\nname: {team_name}\nobjective: Quick-start Team Agent team.\n---\n\nQuick-start team.\n",
141
+ encoding="utf-8",
142
+ )
143
+ elif name:
144
+ # Keep the existing body; name override is only for fresh TEAM.md to avoid hand-editing user docs.
145
+ EventLog(workspace).write("quick_start.name_ignored_existing_team_doc", name=name, team_doc=str(team_doc))
146
+ return team_dir
147
+
148
+
149
+ def wait_ready(workspace: Path, timeout: int = 120) -> dict[str, Any]:
150
+ from team_agent.runtime import status
151
+
152
+ start_time = time.monotonic()
153
+ last: dict[str, Any] = {}
154
+ while time.monotonic() - start_time <= timeout:
155
+ last = status(workspace, as_json=True)
156
+ agents = last.get("agents", {})
157
+ if agents and all(agent.get("tmux_window_present") and agent.get("status") in {"running", "busy"} for agent in agents.values()):
158
+ break
159
+ time.sleep(1.0)
160
+ readiness = {
161
+ "process_started": bool(last.get("tmux_session_present")),
162
+ "cli_prompt_ready": all(agent.get("status") in {"running", "busy"} for agent in last.get("agents", {}).values()) if last.get("agents") else False,
163
+ "mcp_ready": all(Path(agent.get("mcp_config", "")).exists() for agent in last.get("agents", {}).values()) if last.get("agents") else False,
164
+ "task_prompt_delivered": bool(MessageStore(workspace).message_counts()),
165
+ }
166
+ ok = readiness["process_started"] and readiness["cli_prompt_ready"] and readiness["mcp_ready"]
167
+ details_log = logs_dir(workspace) / f"wait-ready-{int(time.time())}.json"
168
+ details_log.write_text(json.dumps({"readiness": readiness, "status": last}, indent=2, ensure_ascii=False), encoding="utf-8")
169
+ return {
170
+ "ok": ok,
171
+ "summary": "workers ready" if ok else "workers not fully ready before timeout",
172
+ "next_actions": ["Dispatch a task with team-agent send."] if ok else ["Run team-agent diagnose --json."],
173
+ "details_log": str(details_log),
174
+ "readiness": readiness,
175
+ }
176
+
177
+
178
+ def settle(workspace: Path) -> dict[str, Any]:
179
+ from team_agent.runtime import collect, status
180
+
181
+ collected = collect(workspace)
182
+ current = status(workspace, as_json=True)
183
+ details_log = logs_dir(workspace) / f"settle-{int(time.time())}.json"
184
+ details_log.write_text(json.dumps({"collect": collected, "status": current}, indent=2, ensure_ascii=False), encoding="utf-8")
185
+ return {
186
+ "ok": collected.get("ok", False),
187
+ "summary": f"collected {len(collected.get('collected', []))} result(s)",
188
+ "next_actions": ["Review team_state.md and decide whether to continue or shutdown."],
189
+ "details_log": str(details_log),
190
+ "collect": collected,
191
+ }
192
+
193
+
194
+ def repair_state(
195
+ workspace: Path,
196
+ task_id: str,
197
+ assignee: str | None = None,
198
+ status_value: str | None = None,
199
+ summary: str | None = None,
200
+ ) -> dict[str, Any]:
201
+ from team_agent.runtime import RuntimeError, _find_task, _leader_id
202
+
203
+ state = load_runtime_state(workspace)
204
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
205
+ spec = load_spec(spec_path)
206
+ task = _find_task(state.get("tasks", []), task_id)
207
+ if assignee is not None:
208
+ valid_agents = {agent["id"] for agent in spec.get("agents", [])}
209
+ valid_agents.add(_leader_id(state, spec))
210
+ if assignee not in valid_agents:
211
+ raise RuntimeError(f"unknown agent id for repair: {assignee}")
212
+ if status_value is not None and status_value not in TASK_STATUSES:
213
+ raise RuntimeError(f"unknown task status for repair: {status_value}")
214
+ before = {
215
+ "assignee": task.get("assignee"),
216
+ "status": task.get("status"),
217
+ "last_result_summary": task.get("last_result_summary"),
218
+ }
219
+ if assignee is not None:
220
+ task["assignee"] = assignee
221
+ if status_value is not None:
222
+ task["status"] = status_value
223
+ if summary is not None:
224
+ task["last_result_summary"] = summary
225
+ after = {
226
+ "assignee": task.get("assignee"),
227
+ "status": task.get("status"),
228
+ "last_result_summary": task.get("last_result_summary"),
229
+ }
230
+ save_runtime_state(workspace, state)
231
+ state_path = write_team_state(workspace, spec, state)
232
+ EventLog(workspace).write("repair_state.task", task_id=task_id, before=before, after=after)
233
+ return {"ok": True, "task_id": task_id, "before": before, "after": after, "state_file": str(state_path)}
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from team_agent.display.close import (
4
+ close_ghostty_display,
5
+ close_ghostty_workspace,
6
+ close_ghostty_workspace_slot,
7
+ )
8
+ from team_agent.display.ghostty import (
9
+ ghostty_app_exists,
10
+ ghostty_attach_args,
11
+ ghostty_command,
12
+ ghostty_display_session_name,
13
+ ghostty_pids_by_title,
14
+ prepare_ghostty_display_session,
15
+ )
16
+ from team_agent.display.worker_window import (
17
+ open_ghostty_worker_window,
18
+ open_worker_displays,
19
+ )
20
+ from team_agent.display.workspace import (
21
+ GHOSTTY_WORKSPACE_PANES_PER_WINDOW,
22
+ ghostty_workspace_aggregator_name,
23
+ ghostty_workspace_blocked,
24
+ ghostty_workspace_pane_command,
25
+ ghostty_workspace_pane_title,
26
+ ghostty_workspace_partial_update_display,
27
+ ghostty_workspace_window_name,
28
+ kill_ghostty_workspace_linked_sessions,
29
+ open_ghostty_workspace,
30
+ open_ghostty_workspace_agent_display,
31
+ prepare_ghostty_workspace_aggregator,
32
+ prepare_ghostty_workspace_linked_sessions,
33
+ set_ghostty_workspace_pane_title,
34
+ )
35
+
36
+ __all__ = [
37
+ "GHOSTTY_WORKSPACE_PANES_PER_WINDOW",
38
+ "close_ghostty_display",
39
+ "close_ghostty_workspace",
40
+ "close_ghostty_workspace_slot",
41
+ "ghostty_app_exists",
42
+ "ghostty_attach_args",
43
+ "ghostty_command",
44
+ "ghostty_display_session_name",
45
+ "ghostty_pids_by_title",
46
+ "ghostty_workspace_aggregator_name",
47
+ "ghostty_workspace_blocked",
48
+ "ghostty_workspace_pane_command",
49
+ "ghostty_workspace_pane_title",
50
+ "ghostty_workspace_partial_update_display",
51
+ "ghostty_workspace_window_name",
52
+ "kill_ghostty_workspace_linked_sessions",
53
+ "open_ghostty_worker_window",
54
+ "open_ghostty_workspace",
55
+ "open_ghostty_workspace_agent_display",
56
+ "open_worker_displays",
57
+ "prepare_ghostty_display_session",
58
+ "prepare_ghostty_workspace_aggregator",
59
+ "prepare_ghostty_workspace_linked_sessions",
60
+ "set_ghostty_workspace_pane_title",
61
+ ]
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from team_agent.events import EventLog
6
+ from team_agent.display.ghostty import ghostty_pids_by_title
7
+ from team_agent.display.workspace import kill_ghostty_workspace_linked_sessions
8
+
9
+
10
+ def close_ghostty_display(
11
+ agent_id: str,
12
+ agent_state: dict[str, Any],
13
+ event_log: EventLog,
14
+ ) -> None:
15
+ from team_agent.runtime import _tmux_session_exists, run_cmd
16
+ display = agent_state.get("display") or {}
17
+ if display.get("backend") != "ghostty_window":
18
+ return
19
+ display_session = display.get("display_session")
20
+ pids = [str(pid) for pid in display.get("pids", []) if str(pid).isdigit()]
21
+ title = display.get("title")
22
+ if not pids and title:
23
+ pids = [str(pid) for pid in ghostty_pids_by_title(str(title))]
24
+ killed: list[str] = []
25
+ for pid in pids:
26
+ proc = run_cmd(["kill", pid], timeout=5)
27
+ if proc.returncode == 0:
28
+ killed.append(pid)
29
+ if killed:
30
+ event_log.write("display.ghostty_closed", agent_id=agent_id, pids=killed, title=title)
31
+ if display_session and _tmux_session_exists(str(display_session)):
32
+ proc = run_cmd(["tmux", "kill-session", "-t", str(display_session)], timeout=10)
33
+ if proc.returncode == 0:
34
+ event_log.write("display.ghostty_display_session_closed", agent_id=agent_id, display_session=display_session)
35
+ else:
36
+ event_log.write(
37
+ "display.ghostty_display_session_close_failed",
38
+ agent_id=agent_id,
39
+ display_session=display_session,
40
+ error=proc.stderr.strip(),
41
+ )
42
+
43
+
44
+ def close_ghostty_workspace_slot(
45
+ agent_id: str,
46
+ display: dict[str, Any],
47
+ event_log: EventLog,
48
+ ) -> None:
49
+ from team_agent.runtime import _tmux_session_exists, run_cmd
50
+ pane_id = display.get("pane_id")
51
+ linked_session = display.get("linked_session")
52
+ stopped_title = f"stopped: {agent_id}"
53
+ relabeled = False
54
+ if pane_id:
55
+ proc = run_cmd(["tmux", "select-pane", "-t", str(pane_id), "-T", stopped_title], timeout=10)
56
+ if proc.returncode == 0:
57
+ relabeled = True
58
+ else:
59
+ event_log.write(
60
+ "display.ghostty_workspace_slot_relabel_failed",
61
+ agent_id=agent_id,
62
+ pane_id=pane_id,
63
+ error=proc.stderr.strip(),
64
+ )
65
+ linked_session_closed = False
66
+ if linked_session and _tmux_session_exists(str(linked_session)):
67
+ proc = run_cmd(["tmux", "kill-session", "-t", str(linked_session)], timeout=10)
68
+ if proc.returncode == 0:
69
+ linked_session_closed = True
70
+ else:
71
+ event_log.write(
72
+ "display.ghostty_workspace_slot_linked_session_close_failed",
73
+ agent_id=agent_id,
74
+ linked_session=linked_session,
75
+ error=proc.stderr.strip(),
76
+ )
77
+ display["status"] = "stopped"
78
+ display["pane_title"] = stopped_title
79
+ event_log.write(
80
+ "display.ghostty_workspace_slot_closed",
81
+ agent_id=agent_id,
82
+ pane_id=pane_id,
83
+ linked_session=linked_session,
84
+ relabeled=relabeled,
85
+ linked_session_closed=linked_session_closed,
86
+ )
87
+
88
+
89
+ def close_ghostty_workspace(state: dict[str, Any], event_log: EventLog) -> None:
90
+ from team_agent.runtime import _tmux_session_exists, run_cmd
91
+ displays = [
92
+ (agent_id, agent_state.get("display") or {})
93
+ for agent_id, agent_state in state.get("agents", {}).items()
94
+ if (agent_state.get("display") or {}).get("backend") == "ghostty_workspace"
95
+ ]
96
+ if not displays:
97
+ return
98
+ aggregator_session = next(
99
+ (
100
+ str(display.get("aggregator_session") or display.get("display_session"))
101
+ for _agent_id, display in displays
102
+ if display.get("aggregator_session") or display.get("display_session")
103
+ ),
104
+ None,
105
+ )
106
+ title = next((str(display.get("title")) for _agent_id, display in displays if display.get("title")), None)
107
+ pids = {
108
+ str(pid)
109
+ for _agent_id, display in displays
110
+ for pid in display.get("pids", [])
111
+ if str(pid).isdigit()
112
+ }
113
+ if not pids and title:
114
+ pids = {str(pid) for pid in ghostty_pids_by_title(str(title))}
115
+
116
+ aggregator_closed = False
117
+ if aggregator_session and _tmux_session_exists(aggregator_session):
118
+ proc = run_cmd(["tmux", "kill-session", "-t", aggregator_session], timeout=10)
119
+ if proc.returncode == 0:
120
+ aggregator_closed = True
121
+ else:
122
+ event_log.write(
123
+ "display.ghostty_workspace_close_failed",
124
+ aggregator_session=aggregator_session,
125
+ error=proc.stderr.strip(),
126
+ )
127
+
128
+ linked_sessions = [
129
+ str(display.get("linked_session"))
130
+ for _agent_id, display in displays
131
+ if display.get("linked_session")
132
+ ]
133
+ linked_closed = kill_ghostty_workspace_linked_sessions(linked_sessions)
134
+
135
+ killed: list[str] = []
136
+ for pid in sorted(pids):
137
+ proc = run_cmd(["kill", pid], timeout=5)
138
+ if proc.returncode == 0:
139
+ killed.append(pid)
140
+ event_log.write(
141
+ "display.ghostty_workspace_closed",
142
+ pids=killed,
143
+ title=title,
144
+ aggregator_session=aggregator_session,
145
+ linked_sessions=linked_closed,
146
+ aggregator_closed=aggregator_closed,
147
+ )
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def ghostty_command() -> str | None:
11
+ from team_agent.runtime import shutil_which
12
+ return shutil_which("ghostty") or (
13
+ "/Applications/Ghostty.app/Contents/MacOS/ghostty"
14
+ if Path("/Applications/Ghostty.app/Contents/MacOS/ghostty").exists()
15
+ else None
16
+ )
17
+
18
+
19
+ def ghostty_app_exists() -> bool:
20
+ return Path("/Applications/Ghostty.app").exists()
21
+
22
+
23
+ def ghostty_pids_by_title(title: str, wait_s: float = 0.0) -> list[int]:
24
+ from team_agent.runtime import run_cmd
25
+ deadline = time.monotonic() + max(wait_s, 0.0)
26
+ while True:
27
+ pgrep = run_cmd(["pgrep", "-f", f"--title={title}"], timeout=5)
28
+ if pgrep.returncode == 0:
29
+ pids = [int(pid) for pid in pgrep.stdout.split() if pid.isdigit()]
30
+ if pids:
31
+ return pids
32
+ if time.monotonic() >= deadline:
33
+ return []
34
+ time.sleep(0.2)
35
+
36
+
37
+ def ghostty_attach_args(display_session: str, title: str) -> list[str]:
38
+ return [
39
+ "open",
40
+ "-na",
41
+ "Ghostty.app",
42
+ "--args",
43
+ f"--title={title}",
44
+ "-e",
45
+ "tmux",
46
+ "attach-session",
47
+ "-t",
48
+ display_session,
49
+ ]
50
+
51
+
52
+ def ghostty_display_session_name(session_name: str, window_name: str) -> str:
53
+ raw = f"{session_name}:{window_name}"
54
+ digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:8]
55
+ safe_session = re.sub(r"[^A-Za-z0-9_.-]", "_", session_name)[:80].strip("._-") or "team"
56
+ safe_window = re.sub(r"[^A-Za-z0-9_.-]", "_", window_name)[:40].strip("._-") or "agent"
57
+ return f"{safe_session}__display__{safe_window}__{digest}"
58
+
59
+
60
+ def prepare_ghostty_display_session(session_name: str, window_name: str, display_session: str) -> dict[str, Any]:
61
+ from team_agent.runtime import _tmux_session_exists, _tmux_window_exists, run_cmd
62
+ if not _tmux_window_exists(session_name, window_name):
63
+ return {"ok": False, "reason": "tmux_target_missing"}
64
+ if display_session == session_name:
65
+ return {"ok": False, "reason": "display_session_conflicts_with_base_session"}
66
+ if _tmux_session_exists(display_session):
67
+ proc = run_cmd(["tmux", "kill-session", "-t", display_session], timeout=10)
68
+ if proc.returncode != 0:
69
+ return {"ok": False, "reason": "display_session_cleanup_failed", "error": proc.stderr.strip()}
70
+ proc = run_cmd(["tmux", "new-session", "-d", "-t", session_name, "-s", display_session], timeout=10)
71
+ if proc.returncode != 0:
72
+ return {"ok": False, "reason": "display_session_create_failed", "error": proc.stderr.strip()}
73
+ proc = run_cmd(["tmux", "select-window", "-t", f"{display_session}:{window_name}"], timeout=10)
74
+ if proc.returncode != 0:
75
+ run_cmd(["tmux", "kill-session", "-t", display_session], timeout=10)
76
+ return {"ok": False, "reason": "display_session_select_window_failed", "error": proc.stderr.strip()}
77
+ return {"ok": True, "display_session": display_session}
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from team_agent.events import EventLog
8
+ from team_agent.display.ghostty import (
9
+ ghostty_app_exists,
10
+ ghostty_attach_args,
11
+ ghostty_display_session_name,
12
+ ghostty_pids_by_title,
13
+ prepare_ghostty_display_session,
14
+ )
15
+ from team_agent.display.workspace import open_ghostty_workspace
16
+
17
+
18
+ def open_worker_displays(
19
+ workspace: Path,
20
+ session_name: str,
21
+ jobs: list[tuple[str, dict[str, Any]]],
22
+ event_log: EventLog,
23
+ display_backend: str = "ghostty_window",
24
+ ) -> dict[str, dict[str, Any]]:
25
+ if not jobs:
26
+ return {}
27
+ if display_backend == "ghostty_workspace":
28
+ return open_ghostty_workspace(workspace, session_name, jobs, event_log)
29
+ if len(jobs) == 1:
30
+ agent_id, agent = jobs[0]
31
+ return {agent_id: open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)}
32
+ results: dict[str, dict[str, Any]] = {}
33
+ max_workers = min(4, len(jobs))
34
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
35
+ futures = {
36
+ executor.submit(open_ghostty_worker_window, workspace, session_name, agent_id, agent, event_log): agent_id
37
+ for agent_id, agent in jobs
38
+ }
39
+ for future in as_completed(futures):
40
+ agent_id = futures[future]
41
+ try:
42
+ results[agent_id] = future.result()
43
+ except Exception as exc:
44
+ display = {
45
+ "backend": "ghostty_window",
46
+ "status": "blocked",
47
+ "reason": "display_open_exception",
48
+ "error": str(exc),
49
+ "fallback": "tmux_headless",
50
+ }
51
+ event_log.write("display.ghostty_blocked", agent_id=agent_id, **display)
52
+ results[agent_id] = display
53
+ return results
54
+
55
+
56
+ def open_ghostty_worker_window(
57
+ workspace: Path,
58
+ session_name: str,
59
+ window_name: str,
60
+ agent: dict[str, Any],
61
+ event_log: EventLog,
62
+ ) -> dict[str, Any]:
63
+ from team_agent.runtime import run_cmd
64
+ _ = workspace
65
+ if not ghostty_app_exists():
66
+ blocker = {
67
+ "backend": "ghostty_window",
68
+ "status": "blocked",
69
+ "reason": "ghostty_app_missing",
70
+ "fallback": "tmux_headless",
71
+ }
72
+ event_log.write("display.ghostty_blocked", agent_id=agent["id"], **blocker)
73
+ return blocker
74
+ title = f"team-agent:{agent['id']}:{agent.get('role', '')}"
75
+ display_session = ghostty_display_session_name(session_name, window_name)
76
+ prepared = prepare_ghostty_display_session(session_name, window_name, display_session)
77
+ if not prepared["ok"]:
78
+ blocker = {
79
+ "backend": "ghostty_window",
80
+ "status": "blocked",
81
+ "reason": prepared["reason"],
82
+ "error": prepared.get("error"),
83
+ "target": f"{session_name}:{window_name}",
84
+ "display_session": display_session,
85
+ "fallback": "tmux_headless",
86
+ }
87
+ event_log.write("display.ghostty_blocked", agent_id=agent["id"], **blocker)
88
+ return blocker
89
+ launch_args = ghostty_attach_args(display_session, title)
90
+ proc = run_cmd(launch_args, timeout=10)
91
+ display = {
92
+ "backend": "ghostty_window",
93
+ "status": "opened" if proc.returncode == 0 else "blocked",
94
+ "title": title,
95
+ "target": f"{session_name}:{window_name}",
96
+ "display_session": display_session,
97
+ "launch_args": launch_args,
98
+ "pid": None,
99
+ "pids": [],
100
+ "tty": None,
101
+ "fallback": "tmux_headless",
102
+ "note": "Ghostty opens a dedicated linked tmux session per worker so each display has an independent active window; runtime injection remains tmux-backed.",
103
+ }
104
+ if proc.returncode != 0:
105
+ display["reason"] = proc.stderr.strip() or proc.stdout.strip() or "open Ghostty.app failed"
106
+ else:
107
+ display["pids"] = ghostty_pids_by_title(title, wait_s=3.0)
108
+ display["pid"] = display["pids"][0] if display["pids"] else None
109
+ event_log.write("display.ghostty_window", agent_id=agent["id"], **display)
110
+ return display