@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.
- package/README.md +201 -0
- package/crates/team-agent-core/Cargo.toml +12 -0
- package/crates/team-agent-core/src/lib.rs +287 -0
- package/crates/team-agent-core/src/main.rs +152 -0
- package/examples/team.spec.yaml +206 -0
- package/examples/team_state.md +35 -0
- package/npm/install.mjs +266 -0
- package/package.json +28 -0
- package/pyproject.toml +18 -0
- package/schemas/result-envelope.schema.json +76 -0
- package/schemas/team.schema.json +241 -0
- package/scripts/install.py +88 -0
- package/scripts/run_regression_tests.py +79 -0
- package/skills/team-agent/SKILL.md +173 -0
- package/src/team_agent/__init__.py +3 -0
- package/src/team_agent/__main__.py +5 -0
- package/src/team_agent/cli.py +857 -0
- package/src/team_agent/compiler.py +269 -0
- package/src/team_agent/coordinator.py +62 -0
- package/src/team_agent/errors.py +10 -0
- package/src/team_agent/events.py +37 -0
- package/src/team_agent/fake_worker.py +80 -0
- package/src/team_agent/mcp_server.py +579 -0
- package/src/team_agent/message_store.py +497 -0
- package/src/team_agent/paths.py +45 -0
- package/src/team_agent/permissions.py +123 -0
- package/src/team_agent/profiles.py +882 -0
- package/src/team_agent/providers.py +1045 -0
- package/src/team_agent/routing.py +84 -0
- package/src/team_agent/runtime.py +5213 -0
- package/src/team_agent/rust_core.py +156 -0
- package/src/team_agent/simple_yaml.py +236 -0
- package/src/team_agent/spec.py +308 -0
- package/src/team_agent/state.py +112 -0
- package/src/team_agent/task_graph.py +80 -0
- 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)
|