@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.
- package/crates/team-agent-core/src/lib.rs +50 -5
- package/package.json +1 -1
- package/schemas/team.schema.json +1 -0
- package/src/team_agent/approvals/__init__.py +65 -0
- package/src/team_agent/approvals/constants.py +6 -0
- package/src/team_agent/approvals/parsing.py +176 -0
- package/src/team_agent/approvals/runtime_prompts.py +171 -0
- package/src/team_agent/approvals/status.py +165 -0
- package/src/team_agent/cli/__init__.py +137 -0
- package/src/team_agent/cli/commands.py +339 -0
- package/src/team_agent/cli/e2e.py +202 -0
- package/src/team_agent/cli/helpers.py +137 -0
- package/src/team_agent/cli/parser.py +477 -0
- package/src/team_agent/compiler.py +98 -33
- package/src/team_agent/coordinator/__init__.py +53 -0
- package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
- package/src/team_agent/coordinator/lifecycle.py +334 -0
- package/src/team_agent/coordinator/metadata.py +61 -0
- package/src/team_agent/coordinator/paths.py +17 -0
- package/src/team_agent/diagnose/__init__.py +48 -0
- package/src/team_agent/diagnose/checks.py +101 -0
- package/src/team_agent/diagnose/health.py +241 -0
- package/src/team_agent/diagnose/preflight.py +194 -0
- package/src/team_agent/diagnose/quick_start.py +233 -0
- package/src/team_agent/display/__init__.py +61 -0
- package/src/team_agent/display/close.py +147 -0
- package/src/team_agent/display/ghostty.py +77 -0
- package/src/team_agent/display/worker_window.py +110 -0
- package/src/team_agent/display/workspace.py +473 -0
- package/src/team_agent/launch/__init__.py +41 -0
- package/src/team_agent/launch/bootstrap.py +85 -0
- package/src/team_agent/launch/config.py +106 -0
- package/src/team_agent/launch/core.py +291 -0
- package/src/team_agent/launch/requirements.py +57 -0
- package/src/team_agent/leader/__init__.py +320 -0
- package/src/team_agent/lifecycle/__init__.py +5 -0
- package/src/team_agent/lifecycle/agents.py +226 -0
- package/src/team_agent/lifecycle/operations.py +321 -0
- package/src/team_agent/lifecycle/paste_buffer_hygiene.py +39 -0
- package/src/team_agent/lifecycle/start.py +363 -0
- package/src/team_agent/mcp_server/__init__.py +42 -0
- package/src/team_agent/mcp_server/__main__.py +7 -0
- package/src/team_agent/mcp_server/contracts.py +148 -0
- package/src/team_agent/mcp_server/normalize.py +257 -0
- package/src/team_agent/mcp_server/server.py +150 -0
- package/src/team_agent/mcp_server/tools.py +205 -0
- package/src/team_agent/message_store/__init__.py +23 -0
- package/src/team_agent/message_store/agent_health.py +109 -0
- package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
- package/src/team_agent/message_store/result_watchers.py +102 -0
- package/src/team_agent/message_store/schema.py +266 -0
- package/src/team_agent/messaging/__init__.py +1 -0
- package/src/team_agent/messaging/activity_detector.py +190 -0
- package/src/team_agent/messaging/delivery.py +138 -0
- package/src/team_agent/messaging/deps.py +263 -0
- package/src/team_agent/messaging/idle_alerts.py +323 -0
- package/src/team_agent/messaging/internal_delivery.py +46 -0
- package/src/team_agent/messaging/leader.py +317 -0
- package/src/team_agent/messaging/leader_panes.py +343 -0
- package/src/team_agent/messaging/owner_bypass.py +29 -0
- package/src/team_agent/messaging/result_delivery.py +300 -0
- package/src/team_agent/messaging/results.py +456 -0
- package/src/team_agent/messaging/scheduler.py +428 -0
- package/src/team_agent/messaging/send.py +500 -0
- package/src/team_agent/messaging/session_drift.py +94 -0
- package/src/team_agent/messaging/tmux_io.py +337 -0
- package/src/team_agent/messaging/tmux_prompt.py +229 -0
- package/src/team_agent/orchestrator/__init__.py +376 -0
- package/src/team_agent/orchestrator/plan.py +122 -0
- package/src/team_agent/orchestrator/state.py +128 -0
- package/src/team_agent/profiles/__init__.py +82 -0
- package/src/team_agent/profiles/constants.py +19 -0
- package/src/team_agent/profiles/core.py +407 -0
- package/src/team_agent/profiles/helpers.py +69 -0
- package/src/team_agent/profiles/provider_env.py +188 -0
- package/src/team_agent/profiles/smoke.py +201 -0
- package/src/team_agent/provider_cli/__init__.py +43 -0
- package/src/team_agent/provider_cli/adapter.py +167 -0
- package/src/team_agent/provider_cli/base.py +48 -0
- package/src/team_agent/provider_cli/claude.py +457 -0
- package/src/team_agent/provider_cli/codex.py +319 -0
- package/src/team_agent/provider_cli/copilot.py +8 -0
- package/src/team_agent/provider_cli/fake.py +39 -0
- package/src/team_agent/provider_cli/gemini.py +95 -0
- package/src/team_agent/provider_cli/opencode.py +8 -0
- package/src/team_agent/provider_cli/prompt.py +62 -0
- package/src/team_agent/provider_cli/registry.py +18 -0
- package/src/team_agent/provider_cli/unsupported.py +32 -0
- package/src/team_agent/providers.py +67 -949
- package/src/team_agent/quality_gates.py +104 -0
- package/src/team_agent/restart/__init__.py +34 -0
- package/src/team_agent/restart/orchestration.py +328 -0
- package/src/team_agent/restart/selection.py +89 -0
- package/src/team_agent/restart/snapshot.py +70 -0
- package/src/team_agent/runtime.py +809 -5892
- package/src/team_agent/rust_core.py +22 -5
- package/src/team_agent/sessions/__init__.py +25 -0
- package/src/team_agent/sessions/capture.py +93 -0
- package/src/team_agent/sessions/inventory.py +44 -0
- package/src/team_agent/sessions/resume.py +135 -0
- package/src/team_agent/spec.py +3 -1
- package/src/team_agent/state.py +218 -4
- package/src/team_agent/status/__init__.py +63 -0
- package/src/team_agent/status/approvals.py +52 -0
- package/src/team_agent/status/compact.py +158 -0
- package/src/team_agent/status/constants.py +18 -0
- package/src/team_agent/status/inbox.py +28 -0
- package/src/team_agent/status/peek.py +117 -0
- package/src/team_agent/status/queries.py +168 -0
- package/src/team_agent/terminal.py +57 -0
- package/src/team_agent/cli.py +0 -858
- package/src/team_agent/mcp_server.py +0 -579
- 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
|