@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,320 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ import re
6
+ import signal
7
+ import shlex
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from team_agent.events import EventLog
16
+ from team_agent.state import load_runtime_state, save_runtime_state
17
+
18
+
19
+ def attach_leader(workspace: Path, pane: str | None = None, provider: str = "codex") -> dict[str, Any]:
20
+ from team_agent.message_store import MessageStore
21
+ from team_agent.runtime import _attach_leader_to_state, ensure_workspace_dirs
22
+ ensure_workspace_dirs(workspace)
23
+ state = load_runtime_state(workspace)
24
+ event_log = EventLog(workspace)
25
+ receiver, validation = _attach_leader_to_state(
26
+ workspace,
27
+ state,
28
+ pane=pane,
29
+ provider=provider,
30
+ event_log=event_log,
31
+ source="manual",
32
+ )
33
+ save_runtime_state(workspace, state)
34
+ requeued = MessageStore(workspace).requeue_delivery_exhausted_watchers()
35
+ if requeued:
36
+ event_log.write(
37
+ "leader_receiver.requeued_exhausted_watchers",
38
+ watcher_ids=requeued,
39
+ count=len(requeued),
40
+ trigger="attach_leader",
41
+ )
42
+ for watcher_id in requeued:
43
+ event_log.write(
44
+ "result_watcher.requeued",
45
+ watcher_id=watcher_id,
46
+ trigger="attach_leader",
47
+ new_pane_id=receiver.get("pane_id"),
48
+ )
49
+ return {
50
+ "ok": True,
51
+ "leader_receiver": receiver,
52
+ "validation": validation,
53
+ "requeued_exhausted_watchers": requeued,
54
+ }
55
+
56
+
57
+ def start_leader(
58
+ provider: str,
59
+ provider_args: list[str],
60
+ workspace: Path,
61
+ *,
62
+ attach_existing: bool = False,
63
+ confirm_attach: bool = False,
64
+ attach_session: str | None = None,
65
+ ) -> None:
66
+ plan = leader_start_plan(
67
+ provider,
68
+ provider_args,
69
+ workspace,
70
+ attach_existing=attach_existing,
71
+ confirm_attach=confirm_attach,
72
+ attach_session=attach_session,
73
+ )
74
+ if plan["mode"] == "new_tmux_session" and not sys.stdin.isatty():
75
+ plan = dict(plan)
76
+ argv = list(plan["argv"])
77
+ argv.insert(2, "-d")
78
+ plan["argv"] = argv
79
+ plan["detached"] = True
80
+ EventLog(workspace).write(
81
+ "leader.start",
82
+ provider=provider,
83
+ workspace=str(workspace),
84
+ mode=plan["mode"],
85
+ session_name=plan.get("session_name"),
86
+ argv=plan["argv"],
87
+ )
88
+ _run_leader_plan(plan, workspace)
89
+
90
+
91
+ def leader_start_plan(
92
+ provider: str,
93
+ provider_args: list[str],
94
+ workspace: Path,
95
+ *,
96
+ attach_existing: bool = False,
97
+ confirm_attach: bool = False,
98
+ attach_session: str | None = None,
99
+ ) -> dict[str, Any]:
100
+ from team_agent.runtime import (
101
+ RuntimeError,
102
+ _tmux_session_exists,
103
+ ensure_workspace_dirs,
104
+ get_adapter,
105
+ shutil_which,
106
+ )
107
+ workspace = workspace.resolve()
108
+ ensure_workspace_dirs(workspace)
109
+ adapter = get_adapter(provider)
110
+ if not adapter.is_installed():
111
+ raise RuntimeError(f"Provider {provider} command {adapter.command_name!r} not found")
112
+ argv = [adapter.command_name, *provider_args]
113
+ if attach_session:
114
+ if not confirm_attach:
115
+ raise RuntimeError("--attach-session requires --confirm")
116
+ return {
117
+ "mode": "attach_existing",
118
+ "provider": provider,
119
+ "workspace": str(workspace),
120
+ "session_name": attach_session,
121
+ "argv": ["tmux", "attach-session", "-t", attach_session],
122
+ }
123
+ if os.environ.get("TMUX"):
124
+ return {"mode": "exec_provider", "provider": provider, "workspace": str(workspace), "argv": argv}
125
+ if not shutil_which("tmux"):
126
+ raise RuntimeError("tmux is not installed; install tmux 3.3+ or start the leader from an existing tmux pane")
127
+ session_name = leader_session_name(provider, workspace)
128
+ if _tmux_session_exists(session_name):
129
+ return {
130
+ "mode": "attach_existing",
131
+ "provider": provider,
132
+ "workspace": str(workspace),
133
+ "session_name": session_name,
134
+ "argv": ["tmux", "attach-session", "-t", session_name],
135
+ }
136
+ exports = ""
137
+ if os.environ.get("PATH"):
138
+ exports = f"PATH={shlex.quote(os.environ['PATH'])} "
139
+ shell = f"cd {shlex.quote(str(workspace))} && {exports}exec {shlex.join(argv)}"
140
+ tmux_args = ["tmux", "new-session", "-s", session_name, "-n", provider, "-c", str(workspace)]
141
+ return {
142
+ "mode": "new_tmux_session",
143
+ "provider": provider,
144
+ "workspace": str(workspace),
145
+ "session_name": session_name,
146
+ "argv": [*tmux_args, "sh", "-lc", shell],
147
+ "detached": False,
148
+ }
149
+
150
+
151
+ def _run_leader_plan(plan: dict[str, Any], workspace: Path) -> None:
152
+ session_name = plan.get("session_name")
153
+ proc: subprocess.Popen[Any] | None = None
154
+ sigints = 0
155
+
156
+ def stop_process_tree() -> None:
157
+ if session_name and plan["mode"] == "new_tmux_session":
158
+ subprocess.run(["tmux", "kill-session", "-t", str(session_name)], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
159
+ if proc and proc.poll() is None:
160
+ proc.terminate()
161
+
162
+ def handle_sigint(signum: int, _frame: Any) -> None:
163
+ nonlocal sigints
164
+ sigints += 1
165
+ if proc and proc.poll() is None:
166
+ try:
167
+ proc.send_signal(signum)
168
+ except ProcessLookupError:
169
+ pass
170
+ if sigints >= 2:
171
+ stop_process_tree()
172
+
173
+ old_sigint = signal.signal(signal.SIGINT, handle_sigint)
174
+ try:
175
+ if plan["mode"] == "exec_provider":
176
+ os.chdir(workspace)
177
+ proc = subprocess.Popen(plan["argv"])
178
+ if plan.get("detached") and session_name:
179
+ proc.wait()
180
+ while _tmux_session_exists_local(str(session_name)):
181
+ time.sleep(0.2)
182
+ else:
183
+ proc.wait()
184
+ finally:
185
+ signal.signal(signal.SIGINT, old_sigint)
186
+ _print_team_running_reminder(workspace)
187
+ raise SystemExit(proc.returncode if proc else 1)
188
+
189
+
190
+ def _tmux_session_exists_local(session_name: str) -> bool:
191
+ proc = subprocess.run(["tmux", "has-session", "-t", session_name], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
192
+ return proc.returncode == 0
193
+
194
+
195
+ def _print_team_running_reminder(workspace: Path) -> None:
196
+ state = load_runtime_state(workspace)
197
+ team_name = state.get("session_name")
198
+ if not team_name or not _tmux_session_exists_local(str(team_name)):
199
+ return
200
+ print(f"team {team_name} is still running; run team-agent shutdown to close it OR team-agent attach-leader to reconnect.")
201
+
202
+
203
+ def leader_session_name(provider: str, workspace: Path) -> str:
204
+ digest = hashlib.sha1(str(workspace.resolve()).encode("utf-8")).hexdigest()[:8]
205
+ folder = re.sub(r"[^A-Za-z0-9_.-]", "_", workspace.name)[:48].strip("._-") or "workspace"
206
+ return f"team-agent-leader-{provider}-{folder}-{digest}"
207
+
208
+
209
+ def attach_leader_to_state(
210
+ workspace: Path,
211
+ state: dict[str, Any],
212
+ pane: str | None,
213
+ provider: str,
214
+ event_log: EventLog,
215
+ source: str,
216
+ require_current: bool = False,
217
+ ) -> tuple[dict[str, Any], dict[str, Any]]:
218
+ from team_agent.runtime import (
219
+ RuntimeError,
220
+ _leader_command_provider,
221
+ _resolve_leader_pane,
222
+ _target_fingerprint,
223
+ _validate_leader_receiver,
224
+ get_adapter,
225
+ )
226
+ get_adapter(provider)
227
+ pane_info, discovery = _resolve_leader_pane(pane, provider, workspace=workspace, require_current=require_current)
228
+ inferred_provider = _leader_command_provider(pane_info.get("pane_current_command", ""))
229
+ receiver_provider = inferred_provider or provider
230
+ receiver = {
231
+ "mode": "direct_tmux",
232
+ "status": "attached",
233
+ "provider": receiver_provider,
234
+ "pane_id": pane_info["pane_id"],
235
+ "session_name": pane_info["session_name"],
236
+ "window_index": pane_info["window_index"],
237
+ "window_name": pane_info["window_name"],
238
+ "pane_index": pane_info["pane_index"],
239
+ "pane_tty": pane_info["pane_tty"],
240
+ "pane_current_command": pane_info["pane_current_command"],
241
+ "fingerprint": _target_fingerprint(pane_info),
242
+ "attached_at": datetime.now(timezone.utc).isoformat(),
243
+ "discovery": discovery,
244
+ }
245
+ if receiver_provider != provider:
246
+ receiver["requested_provider"] = provider
247
+ validation = _validate_leader_receiver(receiver)
248
+ if not validation["ok"]:
249
+ event_log.write(
250
+ "leader_receiver.attach_failed",
251
+ target=pane or pane_info.get("pane_id"),
252
+ discovery=discovery,
253
+ provider=provider,
254
+ reason=validation["reason"],
255
+ error=validation.get("error"),
256
+ source=source,
257
+ )
258
+ raise RuntimeError(f"leader pane validation failed: {validation['reason']}")
259
+ if validation.get("warning"):
260
+ receiver["warning"] = validation["warning"]
261
+ state["leader_receiver"] = receiver
262
+ event_log.write(
263
+ "leader_receiver.attached",
264
+ target=receiver["pane_id"],
265
+ session_name=receiver["session_name"],
266
+ window_index=receiver["window_index"],
267
+ window_name=receiver["window_name"],
268
+ pane_index=receiver["pane_index"],
269
+ pane_tty=receiver["pane_tty"],
270
+ pane_current_command=receiver["pane_current_command"],
271
+ provider=receiver_provider,
272
+ requested_provider=provider if receiver_provider != provider else None,
273
+ discovery=discovery,
274
+ source=source,
275
+ )
276
+ return receiver, validation
277
+
278
+
279
+ def autobind_leader_receiver_from_env(
280
+ workspace: Path,
281
+ provider: str,
282
+ source: str,
283
+ ) -> dict[str, Any] | None:
284
+ tmux_pane = os.environ.get("TMUX_PANE")
285
+ if not tmux_pane:
286
+ return None
287
+ from team_agent.runtime import ensure_workspace_dirs
288
+ ensure_workspace_dirs(workspace)
289
+ state = load_runtime_state(workspace)
290
+ event_log = EventLog(workspace)
291
+ try:
292
+ receiver, _validation = attach_leader_to_state(
293
+ workspace,
294
+ state,
295
+ pane=tmux_pane,
296
+ provider=provider,
297
+ event_log=event_log,
298
+ source=source,
299
+ )
300
+ except Exception as exc:
301
+ event_log.write(
302
+ "leader_receiver.autobind_skipped",
303
+ pane=tmux_pane,
304
+ provider=provider,
305
+ source=source,
306
+ error=str(exc),
307
+ )
308
+ return None
309
+ save_runtime_state(workspace, state)
310
+ return receiver
311
+
312
+
313
+ __all__ = [
314
+ "attach_leader",
315
+ "attach_leader_to_state",
316
+ "autobind_leader_receiver_from_env",
317
+ "leader_session_name",
318
+ "leader_start_plan",
319
+ "start_leader",
320
+ ]
@@ -0,0 +1,5 @@
1
+ """Lifecycle/state/spec lane package.
2
+
3
+ Future Team and Agent lifecycle orchestration moves here behind runtime.py
4
+ facade wiring.
5
+ """
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from team_agent.errors import RuntimeError
8
+ from team_agent.events import EventLog
9
+ from team_agent.message_store import MessageStore
10
+ from team_agent.spec import load_spec, validate_spec
11
+ from team_agent.state import (
12
+ check_team_owner,
13
+ load_runtime_state,
14
+ resolve_team_scoped_state,
15
+ save_runtime_state,
16
+ save_team_scoped_state,
17
+ write_spec,
18
+ write_team_state,
19
+ )
20
+
21
+
22
+ def remove_agent(
23
+ workspace: Path,
24
+ agent_id: str,
25
+ *,
26
+ from_spec: bool = False,
27
+ confirm: bool = False,
28
+ force: bool = False,
29
+ team: str | None = None,
30
+ ) -> dict[str, Any]:
31
+ import team_agent.runtime as runtime
32
+
33
+ workspace = workspace.resolve()
34
+ state, refusal = resolve_team_scoped_state(workspace, team)
35
+ if refusal:
36
+ return refusal
37
+ gate = check_team_owner(state)
38
+ if gate:
39
+ return gate
40
+ spec_path = Path(state.get("spec_path", workspace / "team.spec.yaml"))
41
+ spec = load_spec(spec_path)
42
+ agent = _find_worker(spec, agent_id)
43
+ if not agent:
44
+ raise RuntimeError(f"unknown worker agent id: {agent_id}")
45
+
46
+ event_log = EventLog(workspace)
47
+ store = MessageStore(workspace)
48
+ agent_state = state.get("agents", {}).get(agent_id) or {}
49
+ dynamic_role_file = _dynamic_role_file(workspace, agent_id, agent_state)
50
+ dynamic_agent = bool(agent_state.get("dynamic_role_file") or agent.get("forked_from"))
51
+ running = _is_running(runtime, state, agent_id, agent_state)
52
+
53
+ if not dynamic_agent and not (from_spec and confirm):
54
+ return {"ok": False, "agent_id": agent_id, "status": "refused", "reason": "from_spec_confirm_required"}
55
+ if running and not force:
56
+ return {"ok": False, "agent_id": agent_id, "status": "refused", "reason": "force_required"}
57
+
58
+ rollback = _RemoveRollback(workspace, spec_path, spec, state, dynamic_role_file, store, agent_id, False)
59
+ stopped: dict[str, Any] | None = None
60
+ try:
61
+ if running and force:
62
+ stopped = runtime.stop_agent(workspace, agent_id, team=team)
63
+ rollback.restore_running = True
64
+ state, _refusal_after = resolve_team_scoped_state(workspace, team)
65
+ removed_state = copy.deepcopy(state)
66
+ removed_state.get("agents", {}).pop(agent_id, None)
67
+ save_team_scoped_state(workspace, removed_state)
68
+
69
+ removed_spec = copy.deepcopy(spec)
70
+ removed_spec["agents"] = [item for item in removed_spec.get("agents", []) if item.get("id") != agent_id]
71
+ startup_order = removed_spec.get("runtime", {}).get("startup_order")
72
+ if isinstance(startup_order, list):
73
+ removed_spec["runtime"]["startup_order"] = [item for item in startup_order if item != agent_id]
74
+ validate_spec(removed_spec, base_dir=spec_path.parent)
75
+ team_state_path = write_team_state(workspace, removed_spec, removed_state)
76
+ write_spec(spec_path, removed_spec)
77
+
78
+ role_file_removed = _remove_dynamic_role_file(dynamic_role_file, bool(agent_state.get("dynamic_role_file")))
79
+ _delete_agent_health(store, agent_id)
80
+ except Exception as exc:
81
+ rollback_result = rollback.restore(runtime, event_log)
82
+ event_log.write("remove_agent.rollback", agent_id=agent_id, ok=rollback_result["ok"], error=str(exc), rollback=rollback_result)
83
+ raise RuntimeError(f"remove-agent failed for {agent_id}: {exc}; rollback_ok={rollback_result['ok']}") from exc
84
+
85
+ runtime._save_team_runtime_snapshot(workspace, removed_state)
86
+ warning = None
87
+ try:
88
+ # Storage commit is authoritative; final success event logging is best-effort.
89
+ event_log.write(
90
+ "remove_agent.complete",
91
+ agent_id=agent_id,
92
+ from_spec=from_spec,
93
+ force=force,
94
+ stopped=stopped,
95
+ role_file_removed=role_file_removed,
96
+ )
97
+ except Exception as exc:
98
+ warning = f"remove-agent completed but success event logging failed: {exc}"
99
+ return {
100
+ "ok": True,
101
+ "agent_id": agent_id,
102
+ "status": "removed",
103
+ "from_spec": from_spec,
104
+ "force": force,
105
+ "stopped": stopped,
106
+ "state_file": str(team_state_path),
107
+ "role_file_removed": role_file_removed,
108
+ **({"warning": warning} if warning else {}),
109
+ }
110
+
111
+
112
+ class _RemoveRollback:
113
+ def __init__(
114
+ self,
115
+ workspace: Path,
116
+ spec_path: Path,
117
+ spec: dict[str, Any],
118
+ state: dict[str, Any],
119
+ dynamic_role_file: Path,
120
+ store: MessageStore,
121
+ agent_id: str,
122
+ restore_running: bool,
123
+ ) -> None:
124
+ self.workspace = workspace
125
+ self.spec_path = spec_path
126
+ self.spec_text = spec_path.read_text(encoding="utf-8")
127
+ self.spec = copy.deepcopy(spec)
128
+ self.state = copy.deepcopy(state)
129
+ self.team_state_path = workspace / spec.get("context", {}).get("state_file", "team_state.md")
130
+ self.team_state_text = self.team_state_path.read_text(encoding="utf-8") if self.team_state_path.exists() else None
131
+ self.dynamic_role_file = dynamic_role_file
132
+ self.dynamic_role_bytes = dynamic_role_file.read_bytes() if dynamic_role_file.exists() else None
133
+ self.health = copy.deepcopy(store.agent_health().get(agent_id))
134
+ self.agent_id = agent_id
135
+ self.restore_running = restore_running
136
+
137
+ def restore(self, runtime: Any, event_log: EventLog) -> dict[str, Any]:
138
+ errors: list[str] = []
139
+ try:
140
+ self.spec_path.write_text(self.spec_text, encoding="utf-8")
141
+ except Exception as exc:
142
+ errors.append(f"spec:{exc}")
143
+ try:
144
+ save_team_scoped_state(self.workspace, self.state)
145
+ except Exception as exc:
146
+ errors.append(f"workspace_state:{exc}")
147
+ try:
148
+ if self.team_state_text is None:
149
+ self.team_state_path.unlink(missing_ok=True)
150
+ else:
151
+ self.team_state_path.parent.mkdir(parents=True, exist_ok=True)
152
+ self.team_state_path.write_text(self.team_state_text, encoding="utf-8")
153
+ except Exception as exc:
154
+ errors.append(f"team_state:{exc}")
155
+ try:
156
+ if self.dynamic_role_bytes is None:
157
+ self.dynamic_role_file.unlink(missing_ok=True)
158
+ else:
159
+ self.dynamic_role_file.parent.mkdir(parents=True, exist_ok=True)
160
+ self.dynamic_role_file.write_bytes(self.dynamic_role_bytes)
161
+ except Exception as exc:
162
+ errors.append(f"role_file:{exc}")
163
+ try:
164
+ _restore_agent_health(MessageStore(self.workspace), self.agent_id, self.health)
165
+ except Exception as exc:
166
+ errors.append(f"agent_health:{exc}")
167
+ if self.restore_running and not errors:
168
+ try:
169
+ runtime.start_agent(self.workspace, self.agent_id, force=True, allow_fresh=True)
170
+ except Exception as exc:
171
+ errors.append(f"worker_restore:{exc}")
172
+ result = {"ok": not errors, "errors": errors}
173
+ if errors:
174
+ event_log.write("remove_agent.rollback_failed", agent_id=self.agent_id, errors=errors)
175
+ return result
176
+
177
+
178
+ def _find_worker(spec: dict[str, Any], agent_id: str) -> dict[str, Any] | None:
179
+ if spec.get("leader", {}).get("id") == agent_id:
180
+ return None
181
+ for agent in spec.get("agents", []):
182
+ if agent.get("id") == agent_id:
183
+ return agent
184
+ return None
185
+
186
+
187
+ def _dynamic_role_file(workspace: Path, agent_id: str, agent_state: dict[str, Any]) -> Path:
188
+ raw = agent_state.get("dynamic_role_file")
189
+ if raw:
190
+ path = Path(str(raw))
191
+ return path if path.is_absolute() else workspace / path
192
+ return workspace / ".team" / "dynamic-role-files" / f"{agent_id}.md"
193
+
194
+
195
+ def _is_running(runtime: Any, state: dict[str, Any], agent_id: str, agent_state: dict[str, Any]) -> bool:
196
+ if str(agent_state.get("status") or "").lower() in {"running", "busy"}:
197
+ return True
198
+ session_name = state.get("session_name")
199
+ window = agent_state.get("window") or agent_id
200
+ return bool(session_name and runtime._tmux_window_exists(str(session_name), str(window)))
201
+
202
+
203
+ def _remove_dynamic_role_file(path: Path, required: bool) -> bool:
204
+ if path.exists():
205
+ path.unlink()
206
+ return True
207
+ if required:
208
+ raise RuntimeError(f"dynamic role file missing: {path}")
209
+ return False
210
+
211
+
212
+ def _delete_agent_health(store: MessageStore, agent_id: str) -> None:
213
+ store.delete_agent_health(agent_id)
214
+
215
+
216
+ def _restore_agent_health(store: MessageStore, agent_id: str, row: dict[str, Any] | None) -> None:
217
+ if not row:
218
+ _delete_agent_health(store, agent_id)
219
+ return
220
+ store.upsert_agent_health(
221
+ agent_id,
222
+ row.get("status") or "IDLE",
223
+ last_output_at=row.get("last_output_at"),
224
+ context_usage_pct=row.get("context_usage_pct"),
225
+ current_task_id=row.get("current_task_id"),
226
+ )