@team-agent/installer 0.1.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 (36) hide show
  1. package/README.md +201 -0
  2. package/crates/team-agent-core/Cargo.toml +12 -0
  3. package/crates/team-agent-core/src/lib.rs +287 -0
  4. package/crates/team-agent-core/src/main.rs +152 -0
  5. package/examples/team.spec.yaml +206 -0
  6. package/examples/team_state.md +35 -0
  7. package/npm/install.mjs +266 -0
  8. package/package.json +28 -0
  9. package/pyproject.toml +18 -0
  10. package/schemas/result-envelope.schema.json +76 -0
  11. package/schemas/team.schema.json +241 -0
  12. package/scripts/install.py +88 -0
  13. package/scripts/run_regression_tests.py +79 -0
  14. package/skills/team-agent/SKILL.md +173 -0
  15. package/src/team_agent/__init__.py +3 -0
  16. package/src/team_agent/__main__.py +5 -0
  17. package/src/team_agent/cli.py +857 -0
  18. package/src/team_agent/compiler.py +269 -0
  19. package/src/team_agent/coordinator.py +62 -0
  20. package/src/team_agent/errors.py +10 -0
  21. package/src/team_agent/events.py +37 -0
  22. package/src/team_agent/fake_worker.py +80 -0
  23. package/src/team_agent/mcp_server.py +579 -0
  24. package/src/team_agent/message_store.py +497 -0
  25. package/src/team_agent/paths.py +45 -0
  26. package/src/team_agent/permissions.py +123 -0
  27. package/src/team_agent/profiles.py +882 -0
  28. package/src/team_agent/providers.py +1045 -0
  29. package/src/team_agent/routing.py +84 -0
  30. package/src/team_agent/runtime.py +5213 -0
  31. package/src/team_agent/rust_core.py +156 -0
  32. package/src/team_agent/simple_yaml.py +236 -0
  33. package/src/team_agent/spec.py +308 -0
  34. package/src/team_agent/state.py +112 -0
  35. package/src/team_agent/task_graph.py +80 -0
  36. package/templates/team_state.md +32 -0
@@ -0,0 +1,1045 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from team_agent.permissions import resolve_permissions
17
+ from team_agent.paths import repo_root
18
+ from team_agent.profiles import ensure_compatible_claude_mcp_config, prepare_agent_profile_launch
19
+
20
+
21
+ class ResumeUnavailable(RuntimeError):
22
+ pass
23
+
24
+
25
+ class ProviderAdapter:
26
+ provider = ""
27
+ command_name = ""
28
+
29
+ def is_installed(self) -> bool:
30
+ return shutil.which(self.command_name) is not None
31
+
32
+ def version(self) -> str | None:
33
+ if not self.is_installed():
34
+ return None
35
+ for args in ([self.command_name, "--version"], [self.command_name, "version"]):
36
+ try:
37
+ proc = subprocess.run(args, text=True, capture_output=True, timeout=8, check=False)
38
+ except (OSError, subprocess.TimeoutExpired):
39
+ continue
40
+ text = (proc.stdout or proc.stderr).strip()
41
+ if text:
42
+ return text.splitlines()[0]
43
+ return "installed"
44
+
45
+ def auth_hint(self) -> dict[str, Any]:
46
+ return {"status": "unknown", "detail": "adapter cannot verify auth without starting CLI"}
47
+
48
+ def build_command(self, agent: dict[str, Any], workspace: Path, mcp_config: dict[str, Any]) -> list[str]:
49
+ raise NotImplementedError
50
+
51
+ def capture_session_id(
52
+ self,
53
+ agent_id: str,
54
+ spawn_context: dict[str, Any],
55
+ timeout_s: float = 3.0,
56
+ ) -> dict[str, Any] | None:
57
+ _ = agent_id, spawn_context, timeout_s
58
+ return None
59
+
60
+ def build_resume_command(
61
+ self,
62
+ agent_state: dict[str, Any],
63
+ workspace: Path,
64
+ mcp_config: dict[str, Any] | None = None,
65
+ ) -> list[str]:
66
+ _ = workspace, mcp_config
67
+ session_id = agent_state.get("session_id")
68
+ if not session_id:
69
+ raise ResumeUnavailable("session_id is required to resume")
70
+ raise ResumeUnavailable(f"{self.provider} does not support resume")
71
+
72
+ def session_is_resumable(self, agent_state: dict[str, Any], workspace: Path) -> bool:
73
+ _ = workspace
74
+ return bool(agent_state.get("session_id"))
75
+
76
+ def recover_session_id(
77
+ self,
78
+ agent_id: str,
79
+ agent_state: dict[str, Any],
80
+ workspace: Path,
81
+ exclude_session_ids: set[str] | None = None,
82
+ ) -> dict[str, Any] | None:
83
+ _ = agent_id, agent_state, workspace, exclude_session_ids
84
+ return None
85
+
86
+ def mcp_config(self, workspace: Path, agent_id: str) -> dict[str, Any]:
87
+ return {
88
+ "team_orchestrator": {
89
+ "type": "stdio",
90
+ "command": sys.executable,
91
+ "args": ["-m", "team_agent.mcp_server", "--workspace", str(workspace)],
92
+ "env": {
93
+ "TEAM_AGENT_ID": agent_id,
94
+ "PYTHONPATH": str(repo_root() / "src"),
95
+ },
96
+ }
97
+ }
98
+
99
+ def install_mcp(self, workspace: Path, agent_id: str, config: dict[str, Any]) -> Path:
100
+ path = workspace / ".team" / "runtime" / "mcp" / f"{agent_id}.json"
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ path.write_text(json.dumps({"mcpServers": config}, indent=2), encoding="utf-8")
103
+ return path
104
+
105
+ def cleanup_mcp(self, workspace: Path, agent_id: str, mcp_path: Path | None = None) -> None:
106
+ return None
107
+
108
+ def status_patterns(self) -> dict[str, str]:
109
+ return {"idle": "", "processing": "", "error": "Error|Traceback|panic"}
110
+
111
+ def exit_text(self) -> str:
112
+ return "/exit"
113
+
114
+ def handle_startup_prompts(
115
+ self,
116
+ session_name: str,
117
+ window_name: str,
118
+ checks: int = 30,
119
+ sleep_s: float = 0.5,
120
+ ) -> list[dict[str, Any]]:
121
+ return []
122
+
123
+ def handle_runtime_prompts(self, session_name: str, window_name: str) -> list[dict[str, Any]]:
124
+ return []
125
+
126
+ def validate_model(self, model: str | None) -> dict[str, Any]:
127
+ return {"ok": True, "status": "not_checked", "provider": self.provider, "model": model}
128
+
129
+
130
+ class ClaudeCodeAdapter(ProviderAdapter):
131
+ provider = "claude_code"
132
+ command_name = "claude"
133
+
134
+ def build_command(self, agent: dict[str, Any], workspace: Path, mcp_config: dict[str, Any]) -> list[str]:
135
+ session_id = agent.get("_session_id") or str(uuid.uuid4())
136
+ agent["_session_id"] = session_id
137
+ cmd = self._base_command(agent, mcp_config)
138
+ cmd.extend(["--session-id", session_id])
139
+ return cmd
140
+
141
+ def build_resume_command(
142
+ self,
143
+ agent_state: dict[str, Any],
144
+ workspace: Path,
145
+ mcp_config: dict[str, Any] | None = None,
146
+ ) -> list[str]:
147
+ _ = workspace
148
+ session_id = agent_state.get("session_id")
149
+ if not session_id:
150
+ raise ResumeUnavailable("claude resume requires session_id")
151
+ if not self.session_is_resumable(agent_state, workspace):
152
+ raise ResumeUnavailable(f"claude resume transcript not found for session_id {session_id}")
153
+ agent = dict(agent_state.get("_agent_spec") or agent_state)
154
+ cmd = self._base_command(agent, mcp_config or {})
155
+ cmd.extend(["--resume", str(session_id)])
156
+ return cmd
157
+
158
+ def capture_session_id(
159
+ self,
160
+ agent_id: str,
161
+ spawn_context: dict[str, Any],
162
+ timeout_s: float = 3.0,
163
+ ) -> dict[str, Any] | None:
164
+ cwd = spawn_context.get("cwd")
165
+ if not cwd:
166
+ return None
167
+ start = _parse_time(spawn_context.get("spawn_time")) or datetime.now(timezone.utc)
168
+ root = Path(spawn_context.get("claude_projects_root") or Path.home() / ".claude" / "projects")
169
+ deadline = time.monotonic() + max(timeout_s, 0.0)
170
+ exclude = {str(item) for item in spawn_context.get("exclude_session_ids", []) if item}
171
+ predetermined = spawn_context.get("predetermined_session_id")
172
+ allow_older = bool(spawn_context.get("allow_older"))
173
+ while True:
174
+ match = _find_claude_transcript(
175
+ root,
176
+ Path(str(cwd)),
177
+ start,
178
+ agent_id=agent_id,
179
+ predetermined_session_id=str(predetermined) if predetermined else None,
180
+ exclude_session_ids=exclude,
181
+ allow_older=allow_older,
182
+ )
183
+ if match:
184
+ return {
185
+ "session_id": match["session_id"],
186
+ "rollout_path": match["rollout_path"],
187
+ "captured_at": datetime.now(timezone.utc).isoformat(),
188
+ "captured_via": match["captured_via"],
189
+ "attribution_confidence": match["confidence"],
190
+ "spawn_cwd": str(cwd),
191
+ }
192
+ if time.monotonic() >= deadline:
193
+ return None
194
+ time.sleep(0.2)
195
+
196
+ def session_is_resumable(self, agent_state: dict[str, Any], workspace: Path) -> bool:
197
+ session_id = agent_state.get("session_id")
198
+ if not session_id:
199
+ return False
200
+ cwd = Path(str(agent_state.get("spawn_cwd") or workspace))
201
+ root = Path(agent_state.get("claude_projects_root") or Path.home() / ".claude" / "projects")
202
+ path = _claude_transcript_path(root, cwd, str(session_id))
203
+ meta = _read_claude_transcript_meta(path, cwd)
204
+ return bool(meta and meta.get("same_cwd") and meta.get("has_user_message"))
205
+
206
+ def recover_session_id(
207
+ self,
208
+ agent_id: str,
209
+ agent_state: dict[str, Any],
210
+ workspace: Path,
211
+ exclude_session_ids: set[str] | None = None,
212
+ ) -> dict[str, Any] | None:
213
+ cwd = Path(str(agent_state.get("spawn_cwd") or workspace))
214
+ root = Path(agent_state.get("claude_projects_root") or Path.home() / ".claude" / "projects")
215
+ pending_session_id = agent_state.get("_pending_session_id")
216
+ match = _find_claude_transcript(
217
+ root,
218
+ cwd,
219
+ _parse_time(agent_state.get("spawned_at")) or datetime.fromtimestamp(0, timezone.utc),
220
+ agent_id=agent_id,
221
+ predetermined_session_id=str(pending_session_id) if pending_session_id else None,
222
+ exclude_session_ids=exclude_session_ids or set(),
223
+ allow_older=True,
224
+ require_agent_match=True,
225
+ )
226
+ if not match:
227
+ return None
228
+ return {
229
+ "session_id": match["session_id"],
230
+ "rollout_path": match["rollout_path"],
231
+ "captured_at": datetime.now(timezone.utc).isoformat(),
232
+ "captured_via": "fs_repair",
233
+ "attribution_confidence": match["confidence"],
234
+ "spawn_cwd": str(cwd),
235
+ }
236
+
237
+ def _base_command(self, agent: dict[str, Any], mcp_config: dict[str, Any]) -> list[str]:
238
+ prompt = compile_system_prompt(agent)
239
+ cmd = ["claude"]
240
+ if agent.get("_runtime", {}).get("dangerous_auto_approve"):
241
+ cmd.append("--dangerously-skip-permissions")
242
+ else:
243
+ cmd.extend(["--permission-mode", "default"])
244
+ model = _agent_model(agent)
245
+ if model:
246
+ cmd.extend(["--model", model])
247
+ if prompt:
248
+ cmd.extend(["--append-system-prompt", prompt])
249
+ if mcp_config:
250
+ managed_compatible_config = (
251
+ agent.get("auth_mode") == "compatible_api"
252
+ and bool(agent.get("_provider_profile", {}).get("claude_projects_root"))
253
+ )
254
+ if not managed_compatible_config:
255
+ cmd.extend(["--mcp-config", json.dumps({"mcpServers": mcp_config})])
256
+ cmd.append("--strict-mcp-config")
257
+ allowed = set(resolve_permissions(agent)["tools"])
258
+ disallowed = _claude_disallowed_tools(allowed)
259
+ for tool in disallowed:
260
+ cmd.extend(["--disallowedTools", tool])
261
+ return cmd
262
+
263
+ def auth_hint(self) -> dict[str, Any]:
264
+ if not self.is_installed():
265
+ return {"status": "missing", "detail": "claude command not found"}
266
+ try:
267
+ proc = subprocess.run(
268
+ ["claude", "auth", "status"],
269
+ text=True,
270
+ capture_output=True,
271
+ timeout=8,
272
+ check=False,
273
+ )
274
+ except (OSError, subprocess.TimeoutExpired) as exc:
275
+ return {"status": "missing_or_unknown", "detail": f"claude auth status failed: {exc}"}
276
+ text = (proc.stdout or proc.stderr).strip()
277
+ try:
278
+ status = json.loads(text) if text else {}
279
+ except json.JSONDecodeError:
280
+ status = {}
281
+ if status.get("loggedIn") is True or proc.returncode == 0:
282
+ method = status.get("authMethod") or "configured"
283
+ return {"status": "present", "detail": f"claude auth status ok: {method}"}
284
+ return {"status": "missing", "detail": text or "run claude auth login or claude setup-token"}
285
+
286
+ def status_patterns(self) -> dict[str, str]:
287
+ return {"idle": r"[>❯]\s", "processing": r"[✶✢✽✻✳·].*…", "error": "Error|Traceback"}
288
+
289
+ def handle_startup_prompts(
290
+ self,
291
+ session_name: str,
292
+ window_name: str,
293
+ checks: int = 30,
294
+ sleep_s: float = 0.5,
295
+ ) -> list[dict[str, Any]]:
296
+ handled: list[dict[str, Any]] = []
297
+ target = f"{session_name}:{window_name}"
298
+ for _ in range(max(checks, 0)):
299
+ proc = subprocess.run(
300
+ ["tmux", "capture-pane", "-p", "-S", "-", "-t", target],
301
+ text=True,
302
+ capture_output=True,
303
+ timeout=5,
304
+ check=False,
305
+ )
306
+ output = proc.stdout if proc.returncode == 0 else ""
307
+ if "Quick safety check" in output or "Yes, I trust this folder" in output:
308
+ subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=False)
309
+ handled.append({"prompt": "claude_workspace_trust", "action": "sent_enter"})
310
+ break
311
+ if "Claude Code" in output and ("❯" in output or ">" in output):
312
+ break
313
+ if sleep_s > 0:
314
+ time.sleep(sleep_s)
315
+ return handled
316
+
317
+
318
+ class CodexAdapter(ProviderAdapter):
319
+ provider = "codex"
320
+ command_name = "codex"
321
+ _model_catalog_cache: dict[str, Any] | None = None
322
+
323
+ def build_command(self, agent: dict[str, Any], workspace: Path, mcp_config: dict[str, Any]) -> list[str]:
324
+ cmd = self._base_command(agent, mcp_config, resume=False)
325
+ return cmd
326
+
327
+ def build_resume_command(
328
+ self,
329
+ agent_state: dict[str, Any],
330
+ workspace: Path,
331
+ mcp_config: dict[str, Any] | None = None,
332
+ ) -> list[str]:
333
+ _ = workspace
334
+ session_id = agent_state.get("session_id")
335
+ if not session_id:
336
+ raise ResumeUnavailable("codex resume requires session_id")
337
+ agent = dict(agent_state.get("_agent_spec") or agent_state)
338
+ cmd = self._base_command(agent, mcp_config or {}, resume=True)
339
+ cmd.append(str(session_id))
340
+ return cmd
341
+
342
+ def capture_session_id(
343
+ self,
344
+ agent_id: str,
345
+ spawn_context: dict[str, Any],
346
+ timeout_s: float = 3.0,
347
+ ) -> dict[str, Any] | None:
348
+ _ = agent_id
349
+ cwd = spawn_context.get("cwd")
350
+ if not cwd:
351
+ return None
352
+ start = _parse_time(spawn_context.get("spawn_time")) or datetime.now(timezone.utc)
353
+ root = Path(spawn_context.get("sessions_root") or Path.home() / ".codex" / "sessions")
354
+ deadline = time.monotonic() + max(timeout_s, 0.0)
355
+ exclude = {str(item) for item in spawn_context.get("exclude_session_ids", []) if item}
356
+ while True:
357
+ match = _find_codex_rollout(root, Path(str(cwd)), start, exclude_session_ids=exclude)
358
+ if match:
359
+ return {
360
+ "session_id": match["session_id"],
361
+ "rollout_path": match["rollout_path"],
362
+ "captured_at": datetime.now(timezone.utc).isoformat(),
363
+ "captured_via": "fs_watch",
364
+ "attribution_confidence": match["confidence"],
365
+ "spawn_cwd": str(cwd),
366
+ }
367
+ if time.monotonic() >= deadline:
368
+ return None
369
+ time.sleep(0.2)
370
+
371
+ def _base_command(self, agent: dict[str, Any], mcp_config: dict[str, Any], resume: bool) -> list[str]:
372
+ prompt = compile_system_prompt(agent)
373
+ cmd = ["codex"]
374
+ if resume:
375
+ cmd.append("resume")
376
+ cmd.extend(["--no-alt-screen", "--disable", "shell_snapshot", "--disable", "apps"])
377
+ profile_overrides = agent.get("_provider_profile", {}).get("command_overrides", {})
378
+ if profile_overrides.get("codex_profile"):
379
+ cmd.extend(["--profile", str(profile_overrides["codex_profile"])])
380
+ if agent.get("_runtime", {}).get("dangerous_auto_approve"):
381
+ cmd.append("--dangerously-bypass-approvals-and-sandbox")
382
+ else:
383
+ tools = set(resolve_permissions(agent)["tools"])
384
+ sandbox = "workspace-write" if {"fs_write", "execute_bash"} & tools else "read-only"
385
+ cmd.extend(["--sandbox", sandbox, "--ask-for-approval", "on-request"])
386
+ model = _agent_model(agent)
387
+ if model:
388
+ cmd.extend(["--model", model])
389
+ for config in profile_overrides.get("codex_config", []):
390
+ cmd.extend(["-c", str(config)])
391
+ if prompt:
392
+ escaped = prompt.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
393
+ cmd.extend(["-c", f'developer_instructions="{escaped}"'])
394
+ for server_name, cfg in mcp_config.items():
395
+ prefix = f"mcp_servers.{server_name}"
396
+ cmd.extend(["-c", f'{prefix}.command="{cfg["command"]}"'])
397
+ args = "[" + ", ".join(json.dumps(str(arg)) for arg in cfg.get("args", [])) + "]"
398
+ cmd.extend(["-c", f"{prefix}.args={args}"])
399
+ for env_key, env_val in cfg.get("env", {}).items():
400
+ cmd.extend(["-c", f'{prefix}.env.{env_key}="{env_val}"'])
401
+ cmd.extend(["-c", f"{prefix}.tool_timeout_sec=600.0"])
402
+ return cmd
403
+
404
+ def auth_hint(self) -> dict[str, Any]:
405
+ if "OPENAI_API_KEY" in __import__("os").environ:
406
+ return {"status": "present", "detail": "OPENAI_API_KEY is set"}
407
+ if Path.home().joinpath(".codex").exists():
408
+ return {"status": "present", "detail": "~/.codex exists; run codex login if startup fails"}
409
+ return {"status": "missing_or_unknown", "detail": "run codex login or set OPENAI_API_KEY"}
410
+
411
+ def status_patterns(self) -> dict[str, str]:
412
+ return {"idle": r"(›|❯|codex>)", "processing": r"•.*esc to interrupt", "error": "Error|Traceback|panic"}
413
+
414
+ def handle_startup_prompts(
415
+ self,
416
+ session_name: str,
417
+ window_name: str,
418
+ checks: int = 30,
419
+ sleep_s: float = 0.5,
420
+ ) -> list[dict[str, Any]]:
421
+ handled: list[dict[str, Any]] = []
422
+ target = f"{session_name}:{window_name}"
423
+ for _ in range(max(checks, 0)):
424
+ proc = subprocess.run(
425
+ ["tmux", "capture-pane", "-p", "-S", "-", "-t", target],
426
+ text=True,
427
+ capture_output=True,
428
+ timeout=5,
429
+ check=False,
430
+ )
431
+ output = proc.stdout if proc.returncode == 0 else ""
432
+ trust_pos = max(
433
+ output.rfind("Do you trust the contents of this directory?"),
434
+ output.rfind("Press enter to continue"),
435
+ )
436
+ ready_pos = max(output.rfind("OpenAI Codex"), output.rfind("›"), output.rfind("codex>"))
437
+ if trust_pos >= 0 and trust_pos > ready_pos:
438
+ subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=False)
439
+ handled.append({"prompt": "codex_workspace_trust", "action": "sent_enter"})
440
+ break
441
+ if ready_pos >= 0:
442
+ break
443
+ if sleep_s > 0:
444
+ time.sleep(sleep_s)
445
+ return handled
446
+
447
+ def handle_runtime_prompts(self, session_name: str, window_name: str) -> list[dict[str, Any]]:
448
+ _ = session_name, window_name
449
+ return []
450
+
451
+ def validate_model(self, model: str | None) -> dict[str, Any]:
452
+ if not model:
453
+ return {"ok": True, "status": "model_not_set", "provider": self.provider, "model": model}
454
+ catalog = self._model_catalog()
455
+ if not catalog.get("ok"):
456
+ details = {key: value for key, value in catalog.items() if key != "ok"}
457
+ return {"ok": False, "status": "model_catalog_unavailable", "provider": self.provider, "model": model, **details}
458
+ models = catalog.get("models", [])
459
+ slugs = {str(item.get("slug") or "") for item in models if item.get("slug")}
460
+ if model in slugs:
461
+ return {"ok": True, "status": "model_supported", "provider": self.provider, "model": model}
462
+ slug_by_lower = {slug.lower(): slug for slug in slugs}
463
+ display_to_slug = {
464
+ str(item.get("display_name") or "").lower(): str(item.get("slug"))
465
+ for item in models
466
+ if item.get("display_name") and item.get("slug")
467
+ }
468
+ normalized = model.lower()
469
+ suggested = slug_by_lower.get(normalized) or display_to_slug.get(normalized)
470
+ result = {
471
+ "ok": False,
472
+ "status": "unsupported_model",
473
+ "reason": "model_id_not_found",
474
+ "provider": self.provider,
475
+ "model": model,
476
+ "available_models": sorted(slugs),
477
+ }
478
+ if suggested:
479
+ result["reason"] = "model_id_not_exact"
480
+ result["suggested_model"] = suggested
481
+ return result
482
+
483
+ def _model_catalog(self) -> dict[str, Any]:
484
+ if self._model_catalog_cache is not None:
485
+ return self._model_catalog_cache
486
+ if not self.is_installed():
487
+ return {"ok": False, "reason": "codex_command_missing", "command": self.command_name}
488
+ try:
489
+ proc = subprocess.run(
490
+ [self.command_name, "debug", "models"],
491
+ text=True,
492
+ capture_output=True,
493
+ timeout=12,
494
+ check=False,
495
+ )
496
+ except (OSError, subprocess.TimeoutExpired) as exc:
497
+ return {"ok": False, "reason": "model_catalog_command_failed", "command": "codex debug models", "error": str(exc)}
498
+ if proc.returncode != 0:
499
+ return {
500
+ "ok": False,
501
+ "reason": "model_catalog_command_failed",
502
+ "command": "codex debug models",
503
+ "stderr": proc.stderr.strip(),
504
+ }
505
+ try:
506
+ data = json.loads(proc.stdout or "{}")
507
+ except json.JSONDecodeError as exc:
508
+ return {"ok": False, "reason": "model_catalog_parse_failed", "command": "codex debug models", "error": str(exc)}
509
+ models = data.get("models")
510
+ if not isinstance(models, list):
511
+ return {"ok": False, "reason": "model_catalog_shape_invalid", "command": "codex debug models"}
512
+ self._model_catalog_cache = {"ok": True, "command": "codex debug models", "models": models}
513
+ return self._model_catalog_cache
514
+
515
+
516
+ class GeminiCliAdapter(ProviderAdapter):
517
+ provider = "gemini_cli"
518
+ command_name = "gemini"
519
+
520
+ def build_command(self, agent: dict[str, Any], workspace: Path, mcp_config: dict[str, Any]) -> list[str]:
521
+ prompt = compile_system_prompt(agent)
522
+ cmd = ["gemini"]
523
+ if agent.get("_runtime", {}).get("dangerous_auto_approve"):
524
+ cmd.extend(["--yolo", "--sandbox", "false"])
525
+ model = _agent_model(agent)
526
+ if model:
527
+ cmd.extend(["--model", model])
528
+ if prompt:
529
+ cmd.extend(["-i", prompt])
530
+ return cmd
531
+
532
+ def install_mcp(self, workspace: Path, agent_id: str, config: dict[str, Any]) -> Path:
533
+ path = super().install_mcp(workspace, agent_id, config)
534
+ self._register_mcp_servers(path, config)
535
+ return path
536
+
537
+ def cleanup_mcp(self, workspace: Path, agent_id: str, mcp_path: Path | None = None) -> None:
538
+ path = mcp_path or workspace / ".team" / "runtime" / "mcp" / f"{agent_id}.json"
539
+ self._restore_mcp_servers(path)
540
+
541
+ def _register_mcp_servers(self, mcp_path: Path, config: dict[str, Any]) -> None:
542
+ settings_path = Path.home() / ".gemini" / "settings.json"
543
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
544
+ settings = _read_json_object(settings_path)
545
+ mcp_servers = settings.setdefault("mcpServers", {})
546
+ if not isinstance(mcp_servers, dict):
547
+ raise ValueError(f"{settings_path}: mcpServers must be an object")
548
+
549
+ backup = {
550
+ "settings_path": str(settings_path),
551
+ "servers": {name: mcp_servers.get(name) for name in config},
552
+ }
553
+ _gemini_backup_path(mcp_path).write_text(json.dumps(backup, indent=2), encoding="utf-8")
554
+
555
+ for name, server in config.items():
556
+ mcp_servers[name] = {
557
+ "command": server["command"],
558
+ "args": server.get("args", []),
559
+ "env": server.get("env", {}),
560
+ }
561
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
562
+
563
+ def _restore_mcp_servers(self, mcp_path: Path) -> None:
564
+ backup_path = _gemini_backup_path(mcp_path)
565
+ if not backup_path.exists():
566
+ return
567
+ backup = json.loads(backup_path.read_text(encoding="utf-8"))
568
+ settings_path = Path(backup["settings_path"])
569
+ settings = _read_json_object(settings_path)
570
+ mcp_servers = settings.setdefault("mcpServers", {})
571
+ if not isinstance(mcp_servers, dict):
572
+ raise ValueError(f"{settings_path}: mcpServers must be an object")
573
+ for name, previous in backup.get("servers", {}).items():
574
+ if previous is None:
575
+ mcp_servers.pop(name, None)
576
+ else:
577
+ mcp_servers[name] = previous
578
+ settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
579
+ backup_path.unlink(missing_ok=True)
580
+
581
+ def auth_hint(self) -> dict[str, Any]:
582
+ if "GEMINI_API_KEY" in __import__("os").environ:
583
+ return {"status": "present", "detail": "GEMINI_API_KEY is set"}
584
+ if Path.home().joinpath(".gemini").exists():
585
+ return {"status": "present", "detail": "~/.gemini exists; run gemini to verify OAuth"}
586
+ return {"status": "missing_or_unknown", "detail": "run gemini OAuth setup or set GEMINI_API_KEY"}
587
+
588
+ def status_patterns(self) -> dict[str, str]:
589
+ return {"idle": r"\*\s+Type your message", "processing": r"\(esc to cancel", "error": "Error|APIError|Traceback"}
590
+
591
+ def exit_text(self) -> str:
592
+ return "\x04"
593
+
594
+
595
+ class FakeAdapter(ProviderAdapter):
596
+ provider = "fake"
597
+ command_name = sys.executable
598
+
599
+ def build_command(self, agent: dict[str, Any], workspace: Path, mcp_config: dict[str, Any]) -> list[str]:
600
+ return [
601
+ sys.executable,
602
+ "-m",
603
+ "team_agent.fake_worker",
604
+ "--workspace",
605
+ str(workspace),
606
+ "--agent-id",
607
+ agent["id"],
608
+ ]
609
+
610
+ def build_resume_command(
611
+ self,
612
+ agent_state: dict[str, Any],
613
+ workspace: Path,
614
+ mcp_config: dict[str, Any] | None = None,
615
+ ) -> list[str]:
616
+ agent = dict(agent_state.get("_agent_spec") or agent_state)
617
+ agent.setdefault("id", agent_state.get("agent_id") or agent_state.get("id"))
618
+ return self.build_command(agent, workspace, mcp_config or {})
619
+
620
+ def auth_hint(self) -> dict[str, Any]:
621
+ return {"status": "present", "detail": "fake provider is local test worker"}
622
+
623
+ def status_patterns(self) -> dict[str, str]:
624
+ return {"idle": "TEAM_AGENT_FAKE_READY", "processing": "TEAM_AGENT_FAKE_WORKING", "error": "Traceback"}
625
+
626
+
627
+ ADAPTERS: dict[str, ProviderAdapter] = {
628
+ "claude": ClaudeCodeAdapter(),
629
+ "claude_code": ClaudeCodeAdapter(),
630
+ "codex": CodexAdapter(),
631
+ "gemini_cli": GeminiCliAdapter(),
632
+ "fake": FakeAdapter(),
633
+ }
634
+
635
+ TEAMMATE_SYSTEM_PROMPT = """# Team Agent Teammate Runtime Contract
636
+
637
+ You are a teammate in a Team Agent runtime, not the user's primary assistant.
638
+ The user normally talks to the team lead. Plain text you write in this worker
639
+ session is local to this session and is not a team message.
640
+
641
+ Use Team Agent MCP tools for team-visible coordination:
642
+ - Send progress, blockers, permission needs, tool failures, scope changes, and
643
+ long-running status updates with team_orchestrator.send_message(to='leader',
644
+ content='<short message>').
645
+ - Send to another teammate by agent id when coordination is useful, or use
646
+ to='*' to notify every other team member. The runtime resolves only this team
647
+ and excludes your own worker.
648
+ - When the task is complete, call team_orchestrator.report_result exactly once.
649
+ - Do not pass sender, task_id, agent_id, schema_version, or ack fields unless
650
+ doing a low-level compatibility diagnostic. The MCP runtime fills protocol
651
+ fields from the current worker and task state.
652
+
653
+ If you are blocked or cannot continue, message the leader promptly instead of
654
+ waiting silently. If work takes several minutes, send a short progress update.
655
+ """
656
+
657
+
658
+ def get_adapter(provider: str) -> ProviderAdapter:
659
+ try:
660
+ return ADAPTERS[provider]
661
+ except KeyError as exc:
662
+ raise KeyError(f"Unsupported provider: {provider}") from exc
663
+
664
+
665
+ def compile_system_prompt(agent: dict[str, Any]) -> str:
666
+ prompt_cfg = agent.get("system_prompt", {})
667
+ identity = (
668
+ f"You are Team Agent worker `{agent.get('id')}` with role `{agent.get('role')}`. "
669
+ "When asked about your role or identity, answer with this Team Agent worker identity first, "
670
+ "not only the generic provider product identity."
671
+ )
672
+ chunks: list[str] = [identity, TEAMMATE_SYSTEM_PROMPT]
673
+ if prompt_cfg.get("inline"):
674
+ chunks.append(str(prompt_cfg["inline"]))
675
+ if prompt_cfg.get("file"):
676
+ chunks.append(Path(prompt_cfg["file"]).read_text(encoding="utf-8"))
677
+ contract = agent.get("output_contract", {})
678
+ if contract.get("format") == "result_envelope_v1":
679
+ chunks.append(
680
+ "For progress or blockers, call team_orchestrator.send_message(to='leader', content='<short message>'); "
681
+ "for teammate coordination, send to another agent id or to='*' for every other team member. "
682
+ "do not pass sender, task_id, or requires_ack because the MCP runtime fills protocol fields. "
683
+ "the runtime injects it into the attached Codex leader pane when the leader has run attach-leader. "
684
+ "If no leader is attached, the tool returns a fallback/failed result instead of completion. "
685
+ "Final completion must call team_orchestrator.report_result exactly once with a short summary "
686
+ "and optional status/changes/tests; MCP fills schema_version, task_id, and agent_id."
687
+ )
688
+ perms = resolve_permissions(agent)
689
+ if perms["has_prompt_only"]:
690
+ prompt_only = [e["tool"] for e in perms["resolved_tools"] if e["enforcement"] == "prompt_only"]
691
+ chunks.append(
692
+ "Permission note: these tools are prompt-only for this provider and not hard-enforced: "
693
+ + ", ".join(prompt_only)
694
+ )
695
+ return "\n\n".join(chunk for chunk in chunks if chunk)
696
+
697
+
698
+ def shell_command_for_agent(agent: dict[str, Any], workspace: Path, mcp_config: dict[str, Any]) -> str:
699
+ adapter = get_adapter(agent["provider"])
700
+ command_agent = dict(agent)
701
+ profile_launch = command_agent.get("_provider_profile") or prepare_agent_profile_launch(workspace, command_agent)
702
+ if profile_launch:
703
+ command_agent["_provider_profile"] = profile_launch
704
+ agent["_provider_profile"] = profile_launch
705
+ if (
706
+ agent.get("provider") in {"claude", "claude_code"}
707
+ and profile_launch
708
+ and profile_launch.get("auth_mode") == "compatible_api"
709
+ and profile_launch.get("claude_projects_root")
710
+ ):
711
+ ensure_compatible_claude_mcp_config(workspace, agent["id"], mcp_config)
712
+ cmd = adapter.build_command(command_agent, workspace, mcp_config)
713
+ if command_agent.get("_session_id"):
714
+ agent["_session_id"] = command_agent["_session_id"]
715
+ return shell_command(cmd, agent["id"], workspace, profile_launch)
716
+
717
+
718
+ def shell_resume_command_for_agent(
719
+ agent: dict[str, Any],
720
+ agent_state: dict[str, Any],
721
+ workspace: Path,
722
+ mcp_config: dict[str, Any],
723
+ ) -> str:
724
+ adapter = get_adapter(agent["provider"])
725
+ command_agent = dict(agent)
726
+ profile_launch = command_agent.get("_provider_profile") or prepare_agent_profile_launch(workspace, command_agent)
727
+ if profile_launch:
728
+ command_agent["_provider_profile"] = profile_launch
729
+ agent["_provider_profile"] = profile_launch
730
+ if (
731
+ agent.get("provider") in {"claude", "claude_code"}
732
+ and profile_launch
733
+ and profile_launch.get("auth_mode") == "compatible_api"
734
+ and profile_launch.get("claude_projects_root")
735
+ ):
736
+ ensure_compatible_claude_mcp_config(workspace, agent["id"], mcp_config)
737
+ resume_state = dict(agent_state)
738
+ resume_state["_agent_spec"] = command_agent
739
+ cmd = adapter.build_resume_command(resume_state, workspace, mcp_config)
740
+ return shell_command(cmd, agent["id"], workspace, profile_launch)
741
+
742
+
743
+ def shell_command(
744
+ cmd: list[str],
745
+ agent_id: str,
746
+ workspace: Path,
747
+ profile_launch: dict[str, Any] | None = None,
748
+ ) -> str:
749
+ env = {
750
+ "TEAM_AGENT_ID": agent_id,
751
+ "TEAM_AGENT_WORKSPACE": str(workspace),
752
+ "PYTHONPATH": str(repo_root() / "src"),
753
+ }
754
+ if os.environ.get("PATH"):
755
+ # tmux commands inherit the tmux server's old environment, not the
756
+ # current Codex shell. Preserve PATH so local wrappers such as
757
+ # ~/.local/bin/codex remain effective without logging proxy secrets.
758
+ env["PATH"] = os.environ["PATH"]
759
+ exports = " ".join(f"{key}={shlex.quote(value)}" for key, value in env.items())
760
+ source_profile = ""
761
+ env_file = profile_launch.get("env_file") if profile_launch else None
762
+ if env_file:
763
+ source_profile = f". {shlex.quote(str(env_file))} && "
764
+ return f"cd {shlex.quote(str(workspace))} && export {exports} && {source_profile}exec {shlex.join(cmd)}"
765
+
766
+
767
+ def _agent_model(agent: dict[str, Any]) -> str | None:
768
+ if agent.get("model"):
769
+ return str(agent["model"])
770
+ profile_overrides = agent.get("_provider_profile", {}).get("command_overrides", {})
771
+ if profile_overrides.get("model"):
772
+ return str(profile_overrides["model"])
773
+ return None
774
+
775
+
776
+ def _claude_disallowed_tools(allowed: set[str]) -> list[str]:
777
+ mapping = {
778
+ "execute_bash": ["Bash"],
779
+ "fs_read": ["Read"],
780
+ "fs_write": ["Edit", "Write", "MultiEdit", "NotebookEdit"],
781
+ "fs_list": ["Glob", "Grep"],
782
+ }
783
+ disallowed: list[str] = []
784
+ for canonical, native in mapping.items():
785
+ if canonical not in allowed:
786
+ disallowed.extend(native)
787
+ return disallowed
788
+
789
+
790
+ def _read_json_object(path: Path) -> dict[str, Any]:
791
+ if not path.exists():
792
+ return {}
793
+ data = json.loads(path.read_text(encoding="utf-8"))
794
+ if not isinstance(data, dict):
795
+ raise ValueError(f"{path}: expected a JSON object")
796
+ return data
797
+
798
+
799
+ def _gemini_backup_path(mcp_path: Path) -> Path:
800
+ return mcp_path.with_suffix(".gemini-backup.json")
801
+
802
+
803
+ def _find_claude_transcript(
804
+ root: Path,
805
+ cwd: Path,
806
+ spawn_time: datetime,
807
+ *,
808
+ agent_id: str,
809
+ predetermined_session_id: str | None,
810
+ exclude_session_ids: set[str] | None = None,
811
+ allow_older: bool = False,
812
+ require_agent_match: bool = False,
813
+ ) -> dict[str, Any] | None:
814
+ if not root.exists():
815
+ return None
816
+ exclude_session_ids = exclude_session_ids or set()
817
+ if predetermined_session_id and predetermined_session_id not in exclude_session_ids:
818
+ path = _claude_transcript_path(root, cwd, predetermined_session_id)
819
+ meta = _read_claude_transcript_meta(path, cwd)
820
+ if meta and meta.get("same_cwd") and meta.get("has_user_message"):
821
+ return {
822
+ "session_id": str(predetermined_session_id),
823
+ "rollout_path": str(path),
824
+ "timestamp": meta.get("timestamp") or datetime.fromtimestamp(path.stat().st_mtime, timezone.utc),
825
+ "captured_via": "fs_watch",
826
+ "confidence": "high",
827
+ }
828
+ lower_bound = spawn_time - timedelta(seconds=2)
829
+ upper_bound = datetime.now(timezone.utc) + timedelta(seconds=5)
830
+ candidates: list[dict[str, Any]] = []
831
+ for directory in _claude_project_dirs(root, cwd):
832
+ for path in sorted(directory.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)[:300]:
833
+ meta = _read_claude_transcript_meta(path, cwd)
834
+ if not meta or not meta.get("same_cwd") or not meta.get("has_user_message"):
835
+ continue
836
+ session_id = str(meta.get("session_id") or path.stem)
837
+ if session_id in exclude_session_ids:
838
+ continue
839
+ ts = meta.get("timestamp") or datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
840
+ if not allow_older and (ts < lower_bound or ts > upper_bound):
841
+ continue
842
+ text = str(meta.get("text") or "")
843
+ score = _claude_agent_match_score(agent_id, text)
844
+ if score <= 0 and (not allow_older or require_agent_match):
845
+ continue
846
+ candidates.append(
847
+ {
848
+ "session_id": session_id,
849
+ "rollout_path": str(path),
850
+ "timestamp": ts,
851
+ "captured_via": "fs_watch",
852
+ "confidence": "high" if score >= 2 else "medium",
853
+ "score": score,
854
+ }
855
+ )
856
+ if not candidates:
857
+ return None
858
+ candidates.sort(key=lambda item: (item["score"], item["timestamp"]), reverse=True)
859
+ return candidates[0]
860
+
861
+
862
+ def _claude_project_dirs(root: Path, cwd: Path) -> list[Path]:
863
+ direct = _claude_project_dir(root, cwd)
864
+ if direct.exists():
865
+ return [direct]
866
+ try:
867
+ return [path for path in root.iterdir() if path.is_dir()]
868
+ except OSError:
869
+ return []
870
+
871
+
872
+ def _claude_project_dir(root: Path, cwd: Path) -> Path:
873
+ try:
874
+ cwd_text = str(cwd.resolve())
875
+ except OSError:
876
+ cwd_text = str(cwd)
877
+ return root / re.sub(r"[^A-Za-z0-9._-]", "-", cwd_text)
878
+
879
+
880
+ def _claude_transcript_path(root: Path, cwd: Path, session_id: str) -> Path:
881
+ return _claude_project_dir(root, cwd) / f"{session_id}.jsonl"
882
+
883
+
884
+ def _read_claude_transcript_meta(path: Path, cwd: Path | None = None) -> dict[str, Any] | None:
885
+ if not path.exists():
886
+ return None
887
+ session_id: str | None = None
888
+ transcript_cwd: str | None = None
889
+ timestamp: datetime | None = None
890
+ has_user_message = False
891
+ text_parts: list[str] = []
892
+ try:
893
+ with path.open(encoding="utf-8") as handle:
894
+ for index, line in enumerate(handle):
895
+ if index >= 200:
896
+ break
897
+ try:
898
+ data = json.loads(line)
899
+ except json.JSONDecodeError:
900
+ continue
901
+ if not session_id and data.get("sessionId"):
902
+ session_id = str(data.get("sessionId"))
903
+ if not transcript_cwd and data.get("cwd"):
904
+ transcript_cwd = str(data.get("cwd"))
905
+ timestamp = timestamp or _parse_time(data.get("timestamp"))
906
+ if data.get("type") == "user":
907
+ text = _claude_message_text(data.get("message", {}).get("content"))
908
+ if text.strip():
909
+ has_user_message = True
910
+ if sum(len(part) for part in text_parts) < 8000:
911
+ text_parts.append(text[:4000])
912
+ except OSError:
913
+ return None
914
+ same_cwd = True
915
+ if cwd is not None:
916
+ same_cwd = _same_path(transcript_cwd, cwd)
917
+ return {
918
+ "session_id": session_id or path.stem,
919
+ "cwd": transcript_cwd,
920
+ "same_cwd": same_cwd,
921
+ "timestamp": timestamp,
922
+ "has_user_message": has_user_message,
923
+ "text": "\n".join(text_parts),
924
+ }
925
+
926
+
927
+ def _claude_message_text(content: Any) -> str:
928
+ if isinstance(content, str):
929
+ return content
930
+ if isinstance(content, list):
931
+ parts: list[str] = []
932
+ for item in content:
933
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
934
+ parts.append(item["text"])
935
+ elif isinstance(item, dict) and isinstance(item.get("content"), str):
936
+ parts.append(item["content"])
937
+ return "\n".join(parts)
938
+ return ""
939
+
940
+
941
+ def _claude_agent_match_score(agent_id: str, text: str) -> int:
942
+ if not agent_id:
943
+ return 0
944
+ lowered = text.lower()
945
+ agent = agent_id.lower()
946
+ score = 0
947
+ if f"agents/{agent}.md" in lowered or f"agents\\/{agent}.md" in lowered:
948
+ score += 3
949
+ if f"team_agent_id={agent}" in lowered or f"team_agent_id={agent_id}" in text:
950
+ score += 2
951
+ if f"your agent id: {agent}" in lowered:
952
+ score += 2
953
+ return score
954
+
955
+
956
+ def _same_path(value: str | None, path: Path) -> bool:
957
+ if not value:
958
+ return True
959
+ try:
960
+ return Path(value).resolve() == path.resolve()
961
+ except OSError:
962
+ return str(value) == str(path)
963
+
964
+
965
+ def _find_codex_rollout(
966
+ root: Path,
967
+ cwd: Path,
968
+ spawn_time: datetime,
969
+ exclude_session_ids: set[str] | None = None,
970
+ ) -> dict[str, Any] | None:
971
+ if not root.exists():
972
+ return None
973
+ exclude_session_ids = exclude_session_ids or set()
974
+ lower_bound = spawn_time - timedelta(seconds=2)
975
+ upper_bound = datetime.now(timezone.utc) + timedelta(seconds=5)
976
+ candidates: list[dict[str, Any]] = []
977
+ for path in sorted(root.glob("**/rollout-*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)[:1500]:
978
+ meta = _read_codex_session_meta(path)
979
+ if not meta:
980
+ continue
981
+ meta_cwd = meta.get("cwd")
982
+ if not meta_cwd:
983
+ continue
984
+ try:
985
+ same_cwd = Path(str(meta_cwd)).resolve() == cwd.resolve()
986
+ except OSError:
987
+ same_cwd = str(meta_cwd) == str(cwd)
988
+ if not same_cwd:
989
+ continue
990
+ ts = _parse_time(meta.get("timestamp"))
991
+ if ts and (ts < lower_bound or ts > upper_bound):
992
+ continue
993
+ originator = meta.get("originator")
994
+ origin_ok = originator in {"codex-tui", "codex_exec"}
995
+ session_id = meta.get("id") or _rollout_id_from_name(path)
996
+ if not session_id:
997
+ continue
998
+ if str(session_id) in exclude_session_ids:
999
+ continue
1000
+ candidates.append(
1001
+ {
1002
+ "session_id": str(session_id),
1003
+ "rollout_path": str(path),
1004
+ "timestamp": ts or datetime.fromtimestamp(path.stat().st_mtime, timezone.utc),
1005
+ "confidence": "high" if origin_ok and ts else "medium",
1006
+ }
1007
+ )
1008
+ if not candidates:
1009
+ return None
1010
+ candidates.sort(key=lambda item: item["timestamp"])
1011
+ return candidates[0]
1012
+
1013
+
1014
+ def _read_codex_session_meta(path: Path) -> dict[str, Any] | None:
1015
+ try:
1016
+ with path.open(encoding="utf-8") as handle:
1017
+ first = handle.readline()
1018
+ data = json.loads(first)
1019
+ except (OSError, json.JSONDecodeError):
1020
+ return None
1021
+ if "session_meta" in data:
1022
+ payload = data.get("session_meta", {}).get("payload")
1023
+ else:
1024
+ payload = data.get("payload")
1025
+ return payload if isinstance(payload, dict) else None
1026
+
1027
+
1028
+ def _rollout_id_from_name(path: Path) -> str | None:
1029
+ match = re.search(r"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$", path.name)
1030
+ return match.group(1) if match else None
1031
+
1032
+
1033
+ def _parse_time(value: Any) -> datetime | None:
1034
+ if isinstance(value, datetime):
1035
+ return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
1036
+ if not value:
1037
+ return None
1038
+ text = str(value)
1039
+ if text.endswith("Z"):
1040
+ text = text[:-1] + "+00:00"
1041
+ try:
1042
+ dt = datetime.fromisoformat(text)
1043
+ except ValueError:
1044
+ return None
1045
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)