@team-agent/installer 0.1.11 → 0.2.0

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 (110) 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 +135 -0
  10. package/src/team_agent/cli/commands.py +335 -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 +470 -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 +319 -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/start.py +360 -0
  40. package/src/team_agent/mcp_server/__init__.py +42 -0
  41. package/src/team_agent/mcp_server/__main__.py +7 -0
  42. package/src/team_agent/mcp_server/contracts.py +148 -0
  43. package/src/team_agent/mcp_server/normalize.py +257 -0
  44. package/src/team_agent/mcp_server/server.py +150 -0
  45. package/src/team_agent/mcp_server/tools.py +205 -0
  46. package/src/team_agent/message_store/__init__.py +23 -0
  47. package/src/team_agent/message_store/agent_health.py +109 -0
  48. package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
  49. package/src/team_agent/message_store/result_watchers.py +102 -0
  50. package/src/team_agent/message_store/schema.py +266 -0
  51. package/src/team_agent/messaging/__init__.py +1 -0
  52. package/src/team_agent/messaging/activity_detector.py +190 -0
  53. package/src/team_agent/messaging/delivery.py +128 -0
  54. package/src/team_agent/messaging/deps.py +263 -0
  55. package/src/team_agent/messaging/idle_alerts.py +217 -0
  56. package/src/team_agent/messaging/internal_delivery.py +46 -0
  57. package/src/team_agent/messaging/leader.py +317 -0
  58. package/src/team_agent/messaging/leader_panes.py +343 -0
  59. package/src/team_agent/messaging/result_delivery.py +300 -0
  60. package/src/team_agent/messaging/results.py +456 -0
  61. package/src/team_agent/messaging/scheduler.py +418 -0
  62. package/src/team_agent/messaging/send.py +493 -0
  63. package/src/team_agent/messaging/tmux_io.py +337 -0
  64. package/src/team_agent/messaging/tmux_prompt.py +229 -0
  65. package/src/team_agent/orchestrator/__init__.py +376 -0
  66. package/src/team_agent/orchestrator/plan.py +122 -0
  67. package/src/team_agent/orchestrator/state.py +128 -0
  68. package/src/team_agent/profiles/__init__.py +82 -0
  69. package/src/team_agent/profiles/constants.py +19 -0
  70. package/src/team_agent/profiles/core.py +407 -0
  71. package/src/team_agent/profiles/helpers.py +69 -0
  72. package/src/team_agent/profiles/provider_env.py +188 -0
  73. package/src/team_agent/profiles/smoke.py +201 -0
  74. package/src/team_agent/provider_cli/__init__.py +43 -0
  75. package/src/team_agent/provider_cli/adapter.py +167 -0
  76. package/src/team_agent/provider_cli/base.py +48 -0
  77. package/src/team_agent/provider_cli/claude.py +457 -0
  78. package/src/team_agent/provider_cli/codex.py +319 -0
  79. package/src/team_agent/provider_cli/copilot.py +8 -0
  80. package/src/team_agent/provider_cli/fake.py +39 -0
  81. package/src/team_agent/provider_cli/gemini.py +95 -0
  82. package/src/team_agent/provider_cli/opencode.py +8 -0
  83. package/src/team_agent/provider_cli/prompt.py +62 -0
  84. package/src/team_agent/provider_cli/registry.py +18 -0
  85. package/src/team_agent/provider_cli/unsupported.py +32 -0
  86. package/src/team_agent/providers.py +67 -949
  87. package/src/team_agent/quality_gates.py +104 -0
  88. package/src/team_agent/restart/__init__.py +34 -0
  89. package/src/team_agent/restart/orchestration.py +328 -0
  90. package/src/team_agent/restart/selection.py +89 -0
  91. package/src/team_agent/restart/snapshot.py +70 -0
  92. package/src/team_agent/runtime.py +802 -5893
  93. package/src/team_agent/rust_core.py +22 -5
  94. package/src/team_agent/sessions/__init__.py +25 -0
  95. package/src/team_agent/sessions/capture.py +93 -0
  96. package/src/team_agent/sessions/inventory.py +44 -0
  97. package/src/team_agent/sessions/resume.py +135 -0
  98. package/src/team_agent/spec.py +3 -1
  99. package/src/team_agent/state.py +204 -4
  100. package/src/team_agent/status/__init__.py +63 -0
  101. package/src/team_agent/status/approvals.py +52 -0
  102. package/src/team_agent/status/compact.py +158 -0
  103. package/src/team_agent/status/constants.py +18 -0
  104. package/src/team_agent/status/inbox.py +28 -0
  105. package/src/team_agent/status/peek.py +117 -0
  106. package/src/team_agent/status/queries.py +168 -0
  107. package/src/team_agent/terminal.py +57 -0
  108. package/src/team_agent/cli.py +0 -858
  109. package/src/team_agent/mcp_server.py +0 -579
  110. package/src/team_agent/profiles.py +0 -882
@@ -0,0 +1,321 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import hashlib
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from team_agent import runtime as _runtime
10
+ from team_agent.errors import RuntimeError
11
+ from team_agent.events import EventLog
12
+ from team_agent.spec import load_spec, validate_spec
13
+ from team_agent.state import (
14
+ SESSION_CAPTURE_FIELDS,
15
+ check_team_owner,
16
+ load_runtime_state,
17
+ resolve_team_scoped_state,
18
+ save_runtime_state,
19
+ save_team_scoped_state,
20
+ write_spec,
21
+ write_team_state,
22
+ )
23
+
24
+
25
+ _RUNTIME_SYMBOLS = (
26
+ "_capture_agent_session",
27
+ "_close_ghostty_display",
28
+ "_close_ghostty_workspace_slot",
29
+ "_effective_runtime_config",
30
+ "_find_agent",
31
+ "_handle_startup_prompts_and_verify_window",
32
+ "_open_ghostty_worker_window",
33
+ "_open_ghostty_workspace_agent_display",
34
+ "_running_agent_state",
35
+ "_runtime_lock",
36
+ "_save_team_runtime_snapshot",
37
+ "_spec_team_dir",
38
+ "_tmux_start_command_for_agent_window",
39
+ "_tmux_window_exists",
40
+ "ensure_workspace_dirs",
41
+ "get_adapter",
42
+ "run_cmd",
43
+ "shell_fork_command_for_agent",
44
+ "start_agent",
45
+ "start_coordinator",
46
+ )
47
+ for _name in _RUNTIME_SYMBOLS:
48
+ if not hasattr(_runtime, _name):
49
+ raise ImportError(f"team_agent.runtime missing lifecycle operation dependency: {_name}")
50
+
51
+
52
+ def _runtime_proxy(name: str):
53
+ def proxy(*args: Any, **kwargs: Any) -> Any:
54
+ return getattr(_runtime, name)(*args, **kwargs)
55
+
56
+ return proxy
57
+
58
+
59
+ globals().update({_name: _runtime_proxy(_name) for _name in _RUNTIME_SYMBOLS})
60
+
61
+
62
+ def stop_agent(workspace: Path, agent_id: str, *, team: str | None = None) -> dict[str, Any]:
63
+ with _runtime_lock(workspace, "stop-agent"):
64
+ state, refusal = resolve_team_scoped_state(workspace, team)
65
+ if refusal:
66
+ return refusal
67
+ gate = check_team_owner(state)
68
+ if gate:
69
+ return gate
70
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
71
+ spec = load_spec(spec_path)
72
+ agent = _find_agent(spec, agent_id)
73
+ if not agent or spec.get("leader", {}).get("id") == agent_id:
74
+ raise RuntimeError(f"unknown worker agent id: {agent_id}")
75
+ ensure_workspace_dirs(workspace)
76
+ event_log = EventLog(workspace)
77
+ session_name = state.get("session_name") or spec.get("runtime", {}).get("session_name") or f"team-{spec['team']['name']}"
78
+ agent_state = dict(state.get("agents", {}).get(agent_id) or {"provider": agent["provider"], "agent_id": agent_id})
79
+ window = str(agent_state.get("window") or agent_id)
80
+ target = f"{session_name}:{window}"
81
+ stopped = False
82
+ if _tmux_window_exists(session_name, window):
83
+ proc = run_cmd(["tmux", "kill-window", "-t", target], timeout=10)
84
+ if proc.returncode != 0:
85
+ event_log.write("stop_agent.window_stop_failed", agent_id=agent_id, target=target, stderr=proc.stderr.strip())
86
+ raise RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}")
87
+ stopped = True
88
+ _close_ghostty_display(agent_id, agent_state, event_log)
89
+ display = agent_state.get("display") or {}
90
+ if display.get("backend") == "ghostty_workspace":
91
+ _close_ghostty_workspace_slot(agent_id, display, event_log)
92
+ agent_state["display"] = display
93
+ agent_state.update({"status": "stopped", "provider": agent["provider"], "agent_id": agent_id, "window": window})
94
+ state.setdefault("agents", {})[agent_id] = agent_state
95
+ save_team_scoped_state(workspace, state)
96
+ _save_team_runtime_snapshot(workspace, state)
97
+ state_path = write_team_state(workspace, spec, state)
98
+ event_log.write("stop_agent.complete", agent_id=agent_id, target=target, stopped=stopped)
99
+ return {"ok": True, "agent_id": agent_id, "status": "stopped", "target": target, "stopped": stopped, "state_file": str(state_path)}
100
+
101
+
102
+ def reset_agent(workspace: Path, agent_id: str, *, discard_session: bool = False, open_display: bool = True, team: str | None = None) -> dict[str, Any]:
103
+ if not discard_session:
104
+ return {"ok": False, "agent_id": agent_id, "status": "refused", "reason": "discard_session_required"}
105
+ state, refusal = resolve_team_scoped_state(workspace, team)
106
+ if refusal:
107
+ return refusal
108
+ gate = check_team_owner(state)
109
+ if gate:
110
+ return gate
111
+ stopped = stop_agent(workspace, agent_id, team=team)
112
+ state, refusal = resolve_team_scoped_state(workspace, team)
113
+ if refusal:
114
+ return refusal
115
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
116
+ spec = load_spec(spec_path)
117
+ agent_state = dict(state.get("agents", {}).get(agent_id) or {})
118
+ discarded_session_id = agent_state.get("session_id")
119
+ for key in [*SESSION_CAPTURE_FIELDS, "_pending_session_id"]:
120
+ agent_state.pop(key, None)
121
+ agent_state["status"] = "stopped"
122
+ state.setdefault("agents", {})[agent_id] = agent_state
123
+ EventLog(workspace).write("discard.session_tombstone", agent_id=agent_id, discarded_session_id=discarded_session_id)
124
+ save_team_scoped_state(workspace, state)
125
+ write_team_state(workspace, spec, state)
126
+ started = start_agent(workspace, agent_id, force=True, open_display=open_display, allow_fresh=True, team=team)
127
+ EventLog(workspace).write("reset_agent.complete", agent_id=agent_id, stopped=stopped, started=started)
128
+ return {"ok": True, "agent_id": agent_id, "status": "running", "stopped": stopped, "started": started}
129
+
130
+
131
+ def add_agent(workspace: Path, agent_id: str, *, role_file_path: str, open_display: bool = True, team: str | None = None) -> dict[str, Any]:
132
+ from team_agent.compiler import compile_role_doc_agent
133
+
134
+ state, refusal = resolve_team_scoped_state(workspace, team)
135
+ if refusal:
136
+ return refusal
137
+ gate = check_team_owner(state)
138
+ if gate:
139
+ return gate
140
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
141
+ spec = load_spec(spec_path)
142
+ if _find_agent(spec, agent_id):
143
+ raise RuntimeError(f"agent id already exists: {agent_id}")
144
+ team_dir = Path(str(state.get("team_dir"))) if state.get("team_dir") else _spec_team_dir(spec_path, workspace)
145
+ role_file = Path(role_file_path)
146
+ if not role_file.is_absolute():
147
+ role_file = workspace / role_file
148
+ if not role_file.is_file():
149
+ raise RuntimeError(f"role file not found: {role_file}")
150
+ role_bytes = role_file.read_bytes()
151
+ role_sha = hashlib.sha256(role_bytes).hexdigest()
152
+ dynamic_dir = workspace / ".team" / "dynamic-role-files"
153
+ dynamic_path = dynamic_dir / f"{agent_id}.md"
154
+ old_spec_text = spec_path.read_text(encoding="utf-8")
155
+ old_state = copy.deepcopy(state)
156
+ old_dynamic = dynamic_path.read_bytes() if dynamic_path.exists() else None
157
+ event_log = EventLog(workspace)
158
+ try:
159
+ dynamic_dir.mkdir(parents=True, exist_ok=True)
160
+ dynamic_path.write_bytes(role_bytes)
161
+ agent = compile_role_doc_agent(dynamic_path, team_dir, agent_id)
162
+ spec.setdefault("agents", []).append(agent)
163
+ spec.setdefault("runtime", {}).setdefault("startup_order", []).append(agent_id)
164
+ validate_spec(spec, base_dir=spec_path.parent)
165
+ write_spec(spec_path, spec)
166
+ write_team_state(workspace, spec, state)
167
+ started = start_agent(workspace, agent_id, open_display=open_display, allow_fresh=True, team=team)
168
+ state, _refusal_after = resolve_team_scoped_state(workspace, team)
169
+ state["agents"][agent_id]["dynamic_role_file"] = str(dynamic_path.relative_to(workspace))
170
+ state["agents"][agent_id]["role_file_sha"] = role_sha
171
+ save_team_scoped_state(workspace, state)
172
+ state_path = write_team_state(workspace, spec, state)
173
+ except Exception:
174
+ spec_path.write_text(old_spec_text, encoding="utf-8")
175
+ save_team_scoped_state(workspace, old_state)
176
+ if old_dynamic is None:
177
+ dynamic_path.unlink(missing_ok=True)
178
+ else:
179
+ dynamic_path.parent.mkdir(parents=True, exist_ok=True)
180
+ dynamic_path.write_bytes(old_dynamic)
181
+ raise
182
+ event_log.write("add_agent.complete", agent_id=agent_id, role_file=str(dynamic_path), role_file_sha=role_sha, started=started)
183
+ return {
184
+ "ok": True,
185
+ "agent_id": agent_id,
186
+ "new_agent_id": agent_id,
187
+ "status": "running",
188
+ "role_file": str(dynamic_path),
189
+ "role_file_sha": role_sha,
190
+ "started": started,
191
+ "state_file": str(state_path),
192
+ }
193
+
194
+
195
+ def fork_agent(
196
+ workspace: Path,
197
+ source_agent_id: str,
198
+ *,
199
+ as_agent_id: str,
200
+ label: str | None = None,
201
+ open_display: bool = True,
202
+ team: str | None = None,
203
+ ) -> dict[str, Any]:
204
+ state, refusal = resolve_team_scoped_state(workspace, team)
205
+ if refusal:
206
+ return refusal
207
+ gate = check_team_owner(state)
208
+ if gate:
209
+ return gate
210
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
211
+ spec = load_spec(spec_path)
212
+ if _find_agent(spec, as_agent_id):
213
+ raise RuntimeError(f"agent id already exists: {as_agent_id}")
214
+ source_agent = _find_agent(spec, source_agent_id)
215
+ if not source_agent or spec.get("leader", {}).get("id") == source_agent_id:
216
+ raise RuntimeError(f"unknown worker agent id: {source_agent_id}")
217
+ source_state = state.get("agents", {}).get(source_agent_id) or {}
218
+ source_session_id = str(source_state.get("session_id") or "")
219
+ if not source_session_id:
220
+ raise RuntimeError(f"cannot fork {source_agent_id}: source session_id is missing")
221
+ session_name = state.get("session_name") or spec.get("runtime", {}).get("session_name") or f"team-{spec['team']['name']}"
222
+ if _tmux_window_exists(session_name, as_agent_id):
223
+ raise RuntimeError(f"tmux window already exists for fork target: {session_name}:{as_agent_id}")
224
+ new_agent = copy.deepcopy(source_agent)
225
+ new_agent["id"] = as_agent_id
226
+ new_agent["role"] = str(label or new_agent.get("role") or as_agent_id)
227
+ new_agent["forked_from"] = source_agent_id
228
+ new_agent["preferred_for"] = [as_agent_id, new_agent["role"]]
229
+ old_spec_text = spec_path.read_text(encoding="utf-8")
230
+ old_state = copy.deepcopy(state)
231
+ event_log = EventLog(workspace)
232
+ mcp_path: Path | None = None
233
+ try:
234
+ spec.setdefault("agents", []).append(new_agent)
235
+ spec.setdefault("runtime", {}).setdefault("startup_order", []).append(as_agent_id)
236
+ validate_spec(spec, base_dir=spec_path.parent)
237
+ write_spec(spec_path, spec)
238
+ runtime_cfg = _effective_runtime_config(spec.get("runtime", {}))
239
+ adapter = get_adapter(new_agent["provider"])
240
+ if not adapter.supports_session_fork(new_agent):
241
+ raise RuntimeError(f"{new_agent['provider']} does not support native session fork")
242
+ mcp_config = adapter.mcp_config(workspace, as_agent_id)
243
+ mcp_path = adapter.install_mcp(workspace, as_agent_id, mcp_config)
244
+ command_agent = copy.deepcopy(new_agent)
245
+ command_agent["_runtime"] = runtime_cfg
246
+ command = shell_fork_command_for_agent(command_agent, source_session_id, workspace, mcp_config)
247
+ tmux_cmd, tmux_start_mode = _tmux_start_command_for_agent_window(session_name, as_agent_id, command)
248
+ event_log.write(
249
+ "fork_agent.agent_start",
250
+ source_agent_id=source_agent_id,
251
+ new_agent_id=as_agent_id,
252
+ provider=new_agent["provider"],
253
+ source_session_id=source_session_id,
254
+ tmux_start_mode=tmux_start_mode,
255
+ command=command,
256
+ mcp_config=str(mcp_path),
257
+ )
258
+ proc = run_cmd(tmux_cmd)
259
+ if proc.returncode != 0:
260
+ raise RuntimeError(f"failed to fork agent {source_agent_id}: {proc.stderr.strip()}")
261
+ if not _handle_startup_prompts_and_verify_window(
262
+ adapter, event_log, "fork_agent", as_agent_id, new_agent["provider"], session_name, "forked"
263
+ ):
264
+ raise RuntimeError(f"Failed to fork agent {as_agent_id}: tmux window exited after start")
265
+ spawn_time = datetime.now(timezone.utc)
266
+ agent_state = _running_agent_state(workspace, new_agent, {})
267
+ agent_state.update(
268
+ {
269
+ "mcp_config": str(mcp_path),
270
+ "session_name": session_name,
271
+ "spawned_at": spawn_time.isoformat(),
272
+ "forked_from": source_agent_id,
273
+ }
274
+ )
275
+ if command_agent.get("_session_id"):
276
+ agent_state["_pending_session_id"] = command_agent["_session_id"]
277
+ _capture_agent_session(
278
+ workspace,
279
+ as_agent_id,
280
+ agent_state,
281
+ event_log,
282
+ timeout_s=1.5,
283
+ exclude_session_ids={source_session_id},
284
+ )
285
+ if open_display and state.get("display_backend") in {"ghostty", "ghostty_window"}:
286
+ agent_state["display"] = _open_ghostty_worker_window(workspace, session_name, as_agent_id, new_agent, event_log)
287
+ elif open_display and state.get("display_backend") == "ghostty_workspace":
288
+ agent_state["display"] = _open_ghostty_workspace_agent_display(session_name, as_agent_id, new_agent, {}, event_log)
289
+ state.setdefault("agents", {})[as_agent_id] = agent_state
290
+ save_team_scoped_state(workspace, state)
291
+ _save_team_runtime_snapshot(workspace, state)
292
+ state_path = write_team_state(workspace, spec, state)
293
+ coordinator = start_coordinator(workspace)
294
+ except Exception:
295
+ if _tmux_window_exists(session_name, as_agent_id):
296
+ run_cmd(["tmux", "kill-window", "-t", f"{session_name}:{as_agent_id}"], timeout=10)
297
+ if mcp_path is not None:
298
+ try:
299
+ get_adapter(new_agent["provider"]).cleanup_mcp(workspace, as_agent_id, mcp_path)
300
+ except Exception as exc:
301
+ event_log.write("fork_agent.mcp_cleanup_failed", new_agent_id=as_agent_id, error=str(exc))
302
+ spec_path.write_text(old_spec_text, encoding="utf-8")
303
+ save_team_scoped_state(workspace, old_state)
304
+ raise
305
+ event_log.write(
306
+ "fork_agent.complete",
307
+ source_agent_id=source_agent_id,
308
+ new_agent_id=as_agent_id,
309
+ session_id=state["agents"][as_agent_id].get("session_id"),
310
+ coordinator=coordinator,
311
+ )
312
+ return {
313
+ "ok": True,
314
+ "source_agent_id": source_agent_id,
315
+ "new_agent_id": as_agent_id,
316
+ "agent_id": as_agent_id,
317
+ "status": "running",
318
+ "session_id": state["agents"][as_agent_id].get("session_id"),
319
+ "state_file": str(state_path),
320
+ "coordinator": coordinator,
321
+ }
@@ -0,0 +1,360 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from team_agent import runtime as _runtime
9
+ from team_agent.errors import RuntimeError
10
+ from team_agent.events import EventLog
11
+ from team_agent.message_store import MessageStore
12
+ from team_agent.providers import ResumeUnavailable
13
+ from team_agent.spec import load_spec
14
+ from team_agent.state import (
15
+ check_team_owner,
16
+ load_runtime_state,
17
+ resolve_team_scoped_state,
18
+ save_runtime_state,
19
+ save_team_scoped_state,
20
+ write_team_state,
21
+ )
22
+
23
+
24
+ _RUNTIME_SYMBOLS = (
25
+ "_attach_profile_resume_root",
26
+ "_attach_team_profile_dirs",
27
+ "_capture_agent_session",
28
+ "_clear_session_capture_fields",
29
+ "_deliver_pending_message",
30
+ "_effective_runtime_config",
31
+ "_enable_codex_fast_mode",
32
+ "_ensure_agent_start_requirements",
33
+ "_find_agent",
34
+ "_handle_startup_prompts_and_verify_window",
35
+ "_open_ghostty_worker_window",
36
+ "_open_ghostty_workspace_agent_display",
37
+ "_prepare_resume_state",
38
+ "_running_agent_state",
39
+ "_runtime_lock",
40
+ "_spec_team_dir",
41
+ "_tmux_start_command_for_agent_window",
42
+ "_tmux_window_exists",
43
+ "ensure_workspace_dirs",
44
+ "get_adapter",
45
+ "run_cmd",
46
+ "shell_command_for_agent",
47
+ "shell_resume_command_for_agent",
48
+ "start_coordinator",
49
+ )
50
+ for _name in _RUNTIME_SYMBOLS:
51
+ if not hasattr(_runtime, _name):
52
+ raise ImportError(f"team_agent.runtime missing lifecycle start dependency: {_name}")
53
+
54
+
55
+ def _runtime_proxy(name: str):
56
+ def proxy(*args: Any, **kwargs: Any) -> Any:
57
+ return getattr(_runtime, name)(*args, **kwargs)
58
+
59
+ return proxy
60
+
61
+
62
+ globals().update({_name: _runtime_proxy(_name) for _name in _RUNTIME_SYMBOLS})
63
+
64
+
65
+ def _resume_rollout_missing(agent: dict[str, Any], previous: dict[str, Any]) -> bool:
66
+ if agent.get("provider") != "codex" or not previous.get("session_id"):
67
+ return False
68
+ rollout_path = previous.get("rollout_path")
69
+ return not rollout_path or not Path(str(rollout_path)).exists()
70
+
71
+
72
+ def start_agent(
73
+ workspace: Path,
74
+ agent_id: str,
75
+ force: bool = False,
76
+ open_display: bool = True,
77
+ allow_fresh: bool = False,
78
+ team: str | None = None,
79
+ ) -> dict[str, Any]:
80
+ with _runtime_lock(workspace, "start-agent"):
81
+ return _start_agent_unlocked(workspace, agent_id, force=force, open_display=open_display, allow_fresh=allow_fresh, team=team)
82
+
83
+
84
+ def _start_agent_unlocked(workspace: Path, agent_id: str, force: bool, open_display: bool, allow_fresh: bool, team: str | None = None) -> dict[str, Any]:
85
+ state, refusal = resolve_team_scoped_state(workspace, team)
86
+ if refusal:
87
+ return refusal
88
+ gate = check_team_owner(state)
89
+ if gate:
90
+ return gate
91
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
92
+ if not spec_path.exists():
93
+ raise RuntimeError(f"missing spec for start-agent: {spec_path}")
94
+ spec = load_spec(spec_path)
95
+ team_dir = Path(str(state.get("team_dir"))) if state.get("team_dir") else _spec_team_dir(spec_path, workspace)
96
+ _attach_team_profile_dirs(spec, spec_path, workspace, team_dir)
97
+ agent = _find_agent(spec, agent_id)
98
+ if not agent or spec.get("leader", {}).get("id") == agent_id:
99
+ raise RuntimeError(f"unknown worker agent id: {agent_id}")
100
+ if agent.get("paused"):
101
+ return {"ok": False, "status": "paused", "agent_id": agent_id, "reason": "agent_paused"}
102
+ ensure_workspace_dirs(workspace)
103
+ event_log = EventLog(workspace)
104
+ runtime_cfg = _effective_runtime_config(spec.get("runtime", {}))
105
+ session_name = state.get("session_name") or spec.get("runtime", {}).get("session_name") or f"team-{spec['team']['name']}"
106
+ state["session_name"] = session_name
107
+ state.setdefault("workspace", str(workspace))
108
+ state.setdefault("team_dir", str(team_dir))
109
+ state.setdefault("spec_path", str(spec_path.resolve()))
110
+ state.setdefault("leader", spec.get("leader"))
111
+ state.setdefault("tasks", [dict(task) for task in spec.get("tasks", [])])
112
+ state.setdefault("agents", {})
113
+ state["display_backend"] = spec.get("runtime", {}).get("display_backend", state.get("display_backend") or "none")
114
+
115
+ previous = state.get("agents", {}).get(agent_id, {})
116
+ target = f"{session_name}:{agent_id}"
117
+ window_present = _tmux_window_exists(session_name, agent_id)
118
+ if window_present and not force:
119
+ agent_state = _running_agent_state(workspace, agent, previous)
120
+ agent_state["session_name"] = session_name
121
+ if open_display and state.get("display_backend") in {"ghostty", "ghostty_window"}:
122
+ display = agent_state.get("display") or {}
123
+ if display.get("status") != "opened":
124
+ agent_state["display"] = _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)
125
+ elif open_display and state.get("display_backend") == "ghostty_workspace":
126
+ display = agent_state.get("display") or {}
127
+ if display.get("status") != "opened":
128
+ agent_state["display"] = _open_ghostty_workspace_agent_display(session_name, agent_id, agent, display, event_log)
129
+ state["agents"][agent_id] = agent_state
130
+ save_team_scoped_state(workspace, state)
131
+ write_team_state(workspace, spec, state)
132
+ coordinator = start_coordinator(workspace)
133
+ event_log.write("start_agent.noop", agent_id=agent_id, target=target, coordinator=coordinator)
134
+ return {"ok": True, "agent_id": agent_id, "status": "running", "start_mode": "noop", "target": target, "coordinator": coordinator}
135
+
136
+ if window_present and force:
137
+ proc = run_cmd(["tmux", "kill-window", "-t", target], timeout=10)
138
+ if proc.returncode != 0:
139
+ raise RuntimeError(f"failed to replace existing agent window {target}: {proc.stderr.strip()}")
140
+
141
+ _ensure_agent_start_requirements(workspace, [agent], event_log, "start_agent")
142
+ adapter = get_adapter(agent["provider"])
143
+ if not adapter.is_installed():
144
+ event_log.write("start_agent.provider_missing", agent_id=agent_id, provider=agent["provider"], command=adapter.command_name)
145
+ raise RuntimeError(f"Provider {agent['provider']} command {adapter.command_name!r} not found for agent {agent_id}")
146
+ mcp_config = adapter.mcp_config(workspace, agent_id)
147
+ mcp_path = adapter.install_mcp(workspace, agent_id, mcp_config)
148
+ command_agent = copy.deepcopy(agent)
149
+ command_agent["_runtime"] = runtime_cfg
150
+ previous = _attach_profile_resume_root(workspace, command_agent, previous)
151
+ known_session_ids = {
152
+ str(item.get("session_id"))
153
+ for aid, item in state.get("agents", {}).items()
154
+ if aid != agent_id and item.get("session_id")
155
+ }
156
+ try:
157
+ previous = _prepare_resume_state(
158
+ workspace,
159
+ agent_id,
160
+ previous,
161
+ adapter,
162
+ event_log,
163
+ known_session_ids,
164
+ allow_fresh_on_resume_failure=allow_fresh,
165
+ )
166
+ except ResumeUnavailable as exc:
167
+ try:
168
+ adapter.cleanup_mcp(workspace, agent_id, mcp_path)
169
+ except Exception as cleanup_exc:
170
+ event_log.write(
171
+ "start_agent.mcp_cleanup_failed",
172
+ agent_id=agent_id,
173
+ provider=agent["provider"],
174
+ mcp_config=str(mcp_path),
175
+ error=str(cleanup_exc),
176
+ )
177
+ raise RuntimeError(str(exc)) from exc
178
+ missing_resume_rollout = _resume_rollout_missing(agent, previous)
179
+ start_mode = "resumed" if previous.get("session_id") else "fresh"
180
+ if missing_resume_rollout and allow_fresh:
181
+ event_log.write(
182
+ "start_agent.resume_window_missing_fallback_fresh",
183
+ agent_id=agent_id,
184
+ provider=agent["provider"],
185
+ session_id=previous.get("session_id"),
186
+ reason="rollout_missing",
187
+ )
188
+ start_mode = "fresh_after_missing_rollout"
189
+ previous = dict(previous)
190
+ previous["session_id"] = None
191
+ if start_mode == "resumed":
192
+ try:
193
+ command = shell_resume_command_for_agent(command_agent, previous, workspace, mcp_config)
194
+ except ResumeUnavailable as exc:
195
+ event_log.write("start_agent.resume_unavailable", agent_id=agent_id, error=str(exc))
196
+ if not allow_fresh:
197
+ try:
198
+ adapter.cleanup_mcp(workspace, agent_id, mcp_path)
199
+ except Exception as cleanup_exc:
200
+ event_log.write(
201
+ "start_agent.mcp_cleanup_failed",
202
+ agent_id=agent_id,
203
+ provider=agent["provider"],
204
+ mcp_config=str(mcp_path),
205
+ error=str(cleanup_exc),
206
+ )
207
+ raise RuntimeError(
208
+ f"Cannot resume agent {agent_id}: {exc}. "
209
+ "Use team-agent start-agent --allow-fresh only if losing that worker context is acceptable."
210
+ ) from exc
211
+ command = shell_command_for_agent(command_agent, workspace, mcp_config)
212
+ start_mode = "fresh"
213
+ else:
214
+ command = shell_command_for_agent(command_agent, workspace, mcp_config)
215
+ event_log.write(
216
+ "start_agent.fresh_spawn",
217
+ agent_id=agent_id,
218
+ provider=agent["provider"],
219
+ reason="rollout_missing" if start_mode == "fresh_after_missing_rollout" else "session_id_missing",
220
+ )
221
+
222
+ tmux_cmd, tmux_start_mode = _tmux_start_command_for_agent_window(session_name, agent_id, command)
223
+ event_log.write(
224
+ "start_agent.agent_start",
225
+ agent_id=agent_id,
226
+ provider=agent["provider"],
227
+ start_mode=start_mode,
228
+ session_id=previous.get("session_id"),
229
+ session=session_name,
230
+ window=agent_id,
231
+ tmux_start_mode=tmux_start_mode,
232
+ command=command,
233
+ mcp_config=str(mcp_path),
234
+ )
235
+ proc = run_cmd(tmux_cmd)
236
+ if proc.returncode != 0:
237
+ try:
238
+ adapter.cleanup_mcp(workspace, agent_id, mcp_path)
239
+ except Exception as exc:
240
+ event_log.write("start_agent.mcp_cleanup_failed", agent_id=agent_id, provider=agent["provider"], error=str(exc))
241
+ event_log.write("start_agent.agent_failed", agent_id=agent_id, stderr=proc.stderr, stdout=proc.stdout)
242
+ raise RuntimeError(f"Failed to start agent {agent_id}: {proc.stderr.strip()}")
243
+
244
+ if not _handle_startup_prompts_and_verify_window(
245
+ adapter, event_log, "start_agent", agent_id, agent["provider"], session_name, start_mode
246
+ ):
247
+ if start_mode != "resumed":
248
+ try:
249
+ adapter.cleanup_mcp(workspace, agent_id, mcp_path)
250
+ except Exception as exc:
251
+ event_log.write("start_agent.mcp_cleanup_failed", agent_id=agent_id, provider=agent["provider"], error=str(exc))
252
+ raise RuntimeError(f"Failed to start agent {agent_id}: tmux window exited after start")
253
+ if not allow_fresh:
254
+ try:
255
+ adapter.cleanup_mcp(workspace, agent_id, mcp_path)
256
+ except Exception as cleanup_exc:
257
+ event_log.write(
258
+ "start_agent.mcp_cleanup_failed",
259
+ agent_id=agent_id,
260
+ provider=agent["provider"],
261
+ mcp_config=str(mcp_path),
262
+ error=str(cleanup_exc),
263
+ )
264
+ raise RuntimeError(
265
+ f"Cannot resume agent {agent_id}: resume window exited or did not become visible. "
266
+ "Use team-agent start-agent --allow-fresh only if losing that worker context is acceptable."
267
+ )
268
+ event_log.write(
269
+ "start_agent.resume_window_missing_fallback_fresh",
270
+ agent_id=agent_id,
271
+ provider=agent["provider"],
272
+ session_id=previous.get("session_id"),
273
+ )
274
+ command = shell_command_for_agent(command_agent, workspace, mcp_config)
275
+ start_mode = "fresh_after_missing_rollout" if missing_resume_rollout else "fresh"
276
+ tmux_cmd, tmux_start_mode = _tmux_start_command_for_agent_window(session_name, agent_id, command)
277
+ event_log.write(
278
+ "start_agent.agent_start",
279
+ agent_id=agent_id,
280
+ provider=agent["provider"],
281
+ start_mode=start_mode,
282
+ session_id=None,
283
+ session=session_name,
284
+ window=agent_id,
285
+ tmux_start_mode=tmux_start_mode,
286
+ command=command,
287
+ mcp_config=str(mcp_path),
288
+ )
289
+ proc = run_cmd(tmux_cmd)
290
+ if proc.returncode != 0:
291
+ try:
292
+ adapter.cleanup_mcp(workspace, agent_id, mcp_path)
293
+ except Exception as exc:
294
+ event_log.write("start_agent.mcp_cleanup_failed", agent_id=agent_id, provider=agent["provider"], error=str(exc))
295
+ event_log.write("start_agent.agent_failed", agent_id=agent_id, stderr=proc.stderr, stdout=proc.stdout)
296
+ raise RuntimeError(f"Failed to start agent {agent_id} fresh after resume exit: {proc.stderr.strip()}")
297
+ if not _handle_startup_prompts_and_verify_window(
298
+ adapter, event_log, "start_agent", agent_id, agent["provider"], session_name, start_mode
299
+ ):
300
+ try:
301
+ adapter.cleanup_mcp(workspace, agent_id, mcp_path)
302
+ except Exception as exc:
303
+ event_log.write("start_agent.mcp_cleanup_failed", agent_id=agent_id, provider=agent["provider"], error=str(exc))
304
+ raise RuntimeError(f"Failed to start agent {agent_id} fresh: tmux window exited after start")
305
+ if runtime_cfg.get("fast") and agent.get("provider") == "codex":
306
+ fast_result = _enable_codex_fast_mode(session_name, agent_id)
307
+ event_log.write("start_agent.codex_fast_mode", agent_id=agent_id, **fast_result)
308
+
309
+ spawn_time = datetime.now(timezone.utc)
310
+ agent_state = _running_agent_state(workspace, agent, previous)
311
+ agent_state.update({"mcp_config": str(mcp_path), "session_name": session_name, "spawned_at": spawn_time.isoformat()})
312
+ profile_launch = command_agent.get("_provider_profile") or {}
313
+ if profile_launch.get("claude_projects_root"):
314
+ agent_state["claude_projects_root"] = profile_launch["claude_projects_root"]
315
+ if start_mode in {"fresh", "fresh_after_missing_rollout"}:
316
+ _clear_session_capture_fields(agent_state)
317
+ if command_agent.get("_session_id"):
318
+ agent_state["_pending_session_id"] = command_agent["_session_id"]
319
+ _capture_agent_session(workspace, agent_id, agent_state, event_log, timeout_s=1.5, exclude_session_ids=known_session_ids)
320
+ if open_display and state.get("display_backend") in {"ghostty", "ghostty_window"}:
321
+ agent_state["display"] = _open_ghostty_worker_window(workspace, session_name, agent_id, agent, event_log)
322
+ elif open_display and state.get("display_backend") == "ghostty_workspace":
323
+ agent_state["display"] = _open_ghostty_workspace_agent_display(
324
+ session_name,
325
+ agent_id,
326
+ agent,
327
+ previous.get("display") or {},
328
+ event_log,
329
+ )
330
+ state["agents"][agent_id] = agent_state
331
+ save_team_scoped_state(workspace, state)
332
+ store = MessageStore(workspace)
333
+ delivered_messages: list[str] = []
334
+ for row in store.messages():
335
+ if row["recipient"] == agent_id and row["status"] in {"pending", "accepted"}:
336
+ delivered = _deliver_pending_message(workspace, state, row["message_id"], wait_visible=True, timeout=30.0)
337
+ if delivered.get("ok"):
338
+ delivered_messages.append(row["message_id"])
339
+ event_log.write("send.pending_delivered", message_id=row["message_id"], agent_id=agent_id, source="start_agent")
340
+ write_team_state(workspace, spec, state)
341
+ coordinator = start_coordinator(workspace)
342
+ event_log.write(
343
+ "start_agent.complete",
344
+ agent_id=agent_id,
345
+ session=session_name,
346
+ start_mode=start_mode,
347
+ delivered_messages=delivered_messages,
348
+ coordinator=coordinator,
349
+ )
350
+ return {
351
+ "ok": True,
352
+ "agent_id": agent_id,
353
+ "status": "running",
354
+ "start_mode": start_mode,
355
+ "session_id": agent_state.get("session_id"),
356
+ "target": target,
357
+ "display_target": agent_state.get("display"),
358
+ "delivered_messages": delivered_messages,
359
+ "coordinator": coordinator,
360
+ }