@team-agent/installer 0.1.10 → 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.
- package/crates/team-agent-core/src/lib.rs +50 -5
- package/package.json +1 -1
- package/schemas/team.schema.json +1 -0
- package/skills/team-agent/SKILL.md +1 -1
- package/src/team_agent/approvals/__init__.py +65 -0
- package/src/team_agent/approvals/constants.py +6 -0
- package/src/team_agent/approvals/parsing.py +176 -0
- package/src/team_agent/approvals/runtime_prompts.py +171 -0
- package/src/team_agent/approvals/status.py +165 -0
- package/src/team_agent/cli/__init__.py +135 -0
- package/src/team_agent/cli/commands.py +335 -0
- package/src/team_agent/cli/e2e.py +202 -0
- package/src/team_agent/cli/helpers.py +137 -0
- package/src/team_agent/cli/parser.py +470 -0
- package/src/team_agent/compiler.py +98 -33
- package/src/team_agent/coordinator/__init__.py +53 -0
- package/src/team_agent/{coordinator.py → coordinator/__main__.py} +3 -1
- package/src/team_agent/coordinator/lifecycle.py +319 -0
- package/src/team_agent/coordinator/metadata.py +61 -0
- package/src/team_agent/coordinator/paths.py +17 -0
- package/src/team_agent/diagnose/__init__.py +48 -0
- package/src/team_agent/diagnose/checks.py +101 -0
- package/src/team_agent/diagnose/health.py +241 -0
- package/src/team_agent/diagnose/preflight.py +194 -0
- package/src/team_agent/diagnose/quick_start.py +233 -0
- package/src/team_agent/display/__init__.py +61 -0
- package/src/team_agent/display/close.py +147 -0
- package/src/team_agent/display/ghostty.py +77 -0
- package/src/team_agent/display/worker_window.py +110 -0
- package/src/team_agent/display/workspace.py +473 -0
- package/src/team_agent/launch/__init__.py +41 -0
- package/src/team_agent/launch/bootstrap.py +85 -0
- package/src/team_agent/launch/config.py +106 -0
- package/src/team_agent/launch/core.py +291 -0
- package/src/team_agent/launch/requirements.py +57 -0
- package/src/team_agent/leader/__init__.py +320 -0
- package/src/team_agent/lifecycle/__init__.py +5 -0
- package/src/team_agent/lifecycle/agents.py +226 -0
- package/src/team_agent/lifecycle/operations.py +321 -0
- package/src/team_agent/lifecycle/start.py +360 -0
- package/src/team_agent/mcp_server/__init__.py +42 -0
- package/src/team_agent/mcp_server/__main__.py +7 -0
- package/src/team_agent/mcp_server/contracts.py +148 -0
- package/src/team_agent/mcp_server/normalize.py +257 -0
- package/src/team_agent/mcp_server/server.py +150 -0
- package/src/team_agent/mcp_server/tools.py +205 -0
- package/src/team_agent/message_store/__init__.py +23 -0
- package/src/team_agent/message_store/agent_health.py +109 -0
- package/src/team_agent/{message_store.py → message_store/core.py} +188 -245
- package/src/team_agent/message_store/result_watchers.py +102 -0
- package/src/team_agent/message_store/schema.py +266 -0
- package/src/team_agent/messaging/__init__.py +1 -0
- package/src/team_agent/messaging/activity_detector.py +190 -0
- package/src/team_agent/messaging/delivery.py +128 -0
- package/src/team_agent/messaging/deps.py +263 -0
- package/src/team_agent/messaging/idle_alerts.py +217 -0
- package/src/team_agent/messaging/internal_delivery.py +46 -0
- package/src/team_agent/messaging/leader.py +317 -0
- package/src/team_agent/messaging/leader_panes.py +343 -0
- package/src/team_agent/messaging/result_delivery.py +300 -0
- package/src/team_agent/messaging/results.py +456 -0
- package/src/team_agent/messaging/scheduler.py +418 -0
- package/src/team_agent/messaging/send.py +493 -0
- package/src/team_agent/messaging/tmux_io.py +337 -0
- package/src/team_agent/messaging/tmux_prompt.py +229 -0
- package/src/team_agent/orchestrator/__init__.py +376 -0
- package/src/team_agent/orchestrator/plan.py +122 -0
- package/src/team_agent/orchestrator/state.py +128 -0
- package/src/team_agent/profiles/__init__.py +82 -0
- package/src/team_agent/profiles/constants.py +19 -0
- package/src/team_agent/profiles/core.py +407 -0
- package/src/team_agent/profiles/helpers.py +69 -0
- package/src/team_agent/profiles/provider_env.py +188 -0
- package/src/team_agent/profiles/smoke.py +201 -0
- package/src/team_agent/provider_cli/__init__.py +43 -0
- package/src/team_agent/provider_cli/adapter.py +167 -0
- package/src/team_agent/provider_cli/base.py +48 -0
- package/src/team_agent/provider_cli/claude.py +457 -0
- package/src/team_agent/provider_cli/codex.py +319 -0
- package/src/team_agent/provider_cli/copilot.py +8 -0
- package/src/team_agent/provider_cli/fake.py +39 -0
- package/src/team_agent/provider_cli/gemini.py +95 -0
- package/src/team_agent/provider_cli/opencode.py +8 -0
- package/src/team_agent/provider_cli/prompt.py +62 -0
- package/src/team_agent/provider_cli/registry.py +18 -0
- package/src/team_agent/provider_cli/unsupported.py +32 -0
- package/src/team_agent/providers.py +67 -949
- package/src/team_agent/quality_gates.py +104 -0
- package/src/team_agent/restart/__init__.py +34 -0
- package/src/team_agent/restart/orchestration.py +328 -0
- package/src/team_agent/restart/selection.py +89 -0
- package/src/team_agent/restart/snapshot.py +70 -0
- package/src/team_agent/runtime.py +802 -5740
- package/src/team_agent/rust_core.py +22 -5
- package/src/team_agent/sessions/__init__.py +25 -0
- package/src/team_agent/sessions/capture.py +93 -0
- package/src/team_agent/sessions/inventory.py +44 -0
- package/src/team_agent/sessions/resume.py +135 -0
- package/src/team_agent/spec.py +3 -1
- package/src/team_agent/state.py +204 -4
- package/src/team_agent/status/__init__.py +63 -0
- package/src/team_agent/status/approvals.py +52 -0
- package/src/team_agent/status/compact.py +158 -0
- package/src/team_agent/status/constants.py +18 -0
- package/src/team_agent/status/inbox.py +28 -0
- package/src/team_agent/status/peek.py +117 -0
- package/src/team_agent/status/queries.py +168 -0
- package/src/team_agent/terminal.py +57 -0
- package/src/team_agent/cli.py +0 -857
- package/src/team_agent/mcp_server.py +0 -579
- package/src/team_agent/profiles.py +0 -882
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from team_agent.permissions import resolve_permissions
|
|
13
|
+
from team_agent.provider_cli.adapter import (
|
|
14
|
+
ProviderAdapter,
|
|
15
|
+
ResumeUnavailable,
|
|
16
|
+
agent_model,
|
|
17
|
+
parse_time,
|
|
18
|
+
)
|
|
19
|
+
from team_agent.provider_cli.prompt import compile_system_prompt
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeCodeAdapter(ProviderAdapter):
|
|
23
|
+
provider = "claude_code"
|
|
24
|
+
command_name = "claude"
|
|
25
|
+
|
|
26
|
+
def build_command(self, agent: dict[str, Any], workspace: Path, mcp_config: dict[str, Any]) -> list[str]:
|
|
27
|
+
session_id = agent.get("_session_id") or str(uuid.uuid4())
|
|
28
|
+
agent["_session_id"] = session_id
|
|
29
|
+
cmd = self._base_command(agent, mcp_config)
|
|
30
|
+
cmd.extend(["--session-id", session_id])
|
|
31
|
+
return cmd
|
|
32
|
+
|
|
33
|
+
def build_resume_command(
|
|
34
|
+
self,
|
|
35
|
+
agent_state: dict[str, Any],
|
|
36
|
+
workspace: Path,
|
|
37
|
+
mcp_config: dict[str, Any] | None = None,
|
|
38
|
+
) -> list[str]:
|
|
39
|
+
_ = workspace
|
|
40
|
+
session_id = agent_state.get("session_id")
|
|
41
|
+
if not session_id:
|
|
42
|
+
raise ResumeUnavailable("claude resume requires session_id")
|
|
43
|
+
if not self.session_is_resumable(agent_state, workspace):
|
|
44
|
+
diagnostics = self.session_lookup_diagnostics(agent_state, workspace)
|
|
45
|
+
raise ResumeUnavailable(
|
|
46
|
+
"claude resume transcript not found "
|
|
47
|
+
f"for session_id {session_id}; diagnostics={json.dumps(diagnostics, sort_keys=True)}"
|
|
48
|
+
)
|
|
49
|
+
agent = dict(agent_state.get("_agent_spec") or agent_state)
|
|
50
|
+
cmd = self._base_command(agent, mcp_config or {})
|
|
51
|
+
cmd.extend(["--resume", str(session_id)])
|
|
52
|
+
return cmd
|
|
53
|
+
|
|
54
|
+
def supports_session_fork(self, agent: dict[str, Any] | None = None) -> bool:
|
|
55
|
+
return not agent or agent.get("auth_mode") != "compatible_api"
|
|
56
|
+
|
|
57
|
+
def build_fork_command(
|
|
58
|
+
self,
|
|
59
|
+
agent: dict[str, Any],
|
|
60
|
+
source_session_id: str,
|
|
61
|
+
workspace: Path,
|
|
62
|
+
mcp_config: dict[str, Any],
|
|
63
|
+
) -> list[str]:
|
|
64
|
+
_ = workspace
|
|
65
|
+
if not source_session_id:
|
|
66
|
+
raise ResumeUnavailable("claude fork requires source session_id")
|
|
67
|
+
session_id = agent.get("_session_id") or str(uuid.uuid4())
|
|
68
|
+
agent["_session_id"] = session_id
|
|
69
|
+
cmd = self._base_command(agent, mcp_config)
|
|
70
|
+
cmd.extend(["--session-id", session_id, "--resume", str(source_session_id), "--fork-session"])
|
|
71
|
+
return cmd
|
|
72
|
+
|
|
73
|
+
def capture_session_id(
|
|
74
|
+
self,
|
|
75
|
+
agent_id: str,
|
|
76
|
+
spawn_context: dict[str, Any],
|
|
77
|
+
timeout_s: float = 3.0,
|
|
78
|
+
) -> dict[str, Any] | None:
|
|
79
|
+
cwd = spawn_context.get("cwd")
|
|
80
|
+
if not cwd:
|
|
81
|
+
return None
|
|
82
|
+
start = parse_time(spawn_context.get("spawn_time")) or datetime.now(timezone.utc)
|
|
83
|
+
root = Path(spawn_context.get("claude_projects_root") or Path.home() / ".claude" / "projects")
|
|
84
|
+
deadline = time.monotonic() + max(timeout_s, 0.0)
|
|
85
|
+
exclude = {str(item) for item in spawn_context.get("exclude_session_ids", []) if item}
|
|
86
|
+
predetermined = spawn_context.get("predetermined_session_id")
|
|
87
|
+
allow_older = bool(spawn_context.get("allow_older"))
|
|
88
|
+
while True:
|
|
89
|
+
match = find_claude_transcript(
|
|
90
|
+
root,
|
|
91
|
+
Path(str(cwd)),
|
|
92
|
+
start,
|
|
93
|
+
agent_id=agent_id,
|
|
94
|
+
predetermined_session_id=str(predetermined) if predetermined else None,
|
|
95
|
+
exclude_session_ids=exclude,
|
|
96
|
+
allow_older=allow_older,
|
|
97
|
+
)
|
|
98
|
+
if match:
|
|
99
|
+
return {
|
|
100
|
+
"session_id": match["session_id"],
|
|
101
|
+
"rollout_path": match["rollout_path"],
|
|
102
|
+
"captured_at": datetime.now(timezone.utc).isoformat(),
|
|
103
|
+
"captured_via": match["captured_via"],
|
|
104
|
+
"attribution_confidence": match["confidence"],
|
|
105
|
+
"spawn_cwd": str(cwd),
|
|
106
|
+
}
|
|
107
|
+
if time.monotonic() >= deadline:
|
|
108
|
+
return None
|
|
109
|
+
time.sleep(0.2)
|
|
110
|
+
|
|
111
|
+
def session_is_resumable(self, agent_state: dict[str, Any], workspace: Path) -> bool:
|
|
112
|
+
session_id = agent_state.get("session_id")
|
|
113
|
+
if not session_id:
|
|
114
|
+
return False
|
|
115
|
+
cwd = Path(str(agent_state.get("spawn_cwd") or workspace))
|
|
116
|
+
root = Path(agent_state.get("claude_projects_root") or Path.home() / ".claude" / "projects")
|
|
117
|
+
for path in claude_transcript_paths(root, cwd, str(session_id)):
|
|
118
|
+
meta = read_claude_transcript_meta(path, cwd)
|
|
119
|
+
if meta and meta.get("same_cwd") and meta.get("has_user_message"):
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
def session_lookup_diagnostics(self, agent_state: dict[str, Any], workspace: Path) -> dict[str, Any]:
|
|
124
|
+
session_id = str(agent_state.get("session_id") or "")
|
|
125
|
+
cwd = Path(str(agent_state.get("spawn_cwd") or workspace))
|
|
126
|
+
root = Path(agent_state.get("claude_projects_root") or Path.home() / ".claude" / "projects")
|
|
127
|
+
paths = claude_transcript_paths(root, cwd, session_id) if session_id else []
|
|
128
|
+
return {
|
|
129
|
+
"provider": self.provider,
|
|
130
|
+
"expected_session_id": session_id,
|
|
131
|
+
"spawn_cwd": str(cwd),
|
|
132
|
+
"claude_projects_root": str(root),
|
|
133
|
+
"encoded_dir_claude_actual": claude_project_dir(root, cwd).name,
|
|
134
|
+
"encoded_dir_team_agent_legacy": claude_legacy_project_dir(root, cwd).name,
|
|
135
|
+
"transcript_paths_checked": [str(path) for path in paths],
|
|
136
|
+
"path_exists": {str(path): path.exists() for path in paths},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def recover_session_id(
|
|
140
|
+
self,
|
|
141
|
+
agent_id: str,
|
|
142
|
+
agent_state: dict[str, Any],
|
|
143
|
+
workspace: Path,
|
|
144
|
+
exclude_session_ids: set[str] | None = None,
|
|
145
|
+
) -> dict[str, Any] | None:
|
|
146
|
+
cwd = Path(str(agent_state.get("spawn_cwd") or workspace))
|
|
147
|
+
root = Path(agent_state.get("claude_projects_root") or Path.home() / ".claude" / "projects")
|
|
148
|
+
pending_session_id = agent_state.get("_pending_session_id")
|
|
149
|
+
match = find_claude_transcript(
|
|
150
|
+
root,
|
|
151
|
+
cwd,
|
|
152
|
+
parse_time(agent_state.get("spawned_at")) or datetime.fromtimestamp(0, timezone.utc),
|
|
153
|
+
agent_id=agent_id,
|
|
154
|
+
predetermined_session_id=str(pending_session_id) if pending_session_id else None,
|
|
155
|
+
exclude_session_ids=exclude_session_ids or set(),
|
|
156
|
+
allow_older=True,
|
|
157
|
+
require_agent_match=True,
|
|
158
|
+
require_cwd=True,
|
|
159
|
+
)
|
|
160
|
+
if not match:
|
|
161
|
+
return None
|
|
162
|
+
return {
|
|
163
|
+
"session_id": match["session_id"],
|
|
164
|
+
"rollout_path": match["rollout_path"],
|
|
165
|
+
"captured_at": datetime.now(timezone.utc).isoformat(),
|
|
166
|
+
"captured_via": "fs_repair",
|
|
167
|
+
"attribution_confidence": match["confidence"],
|
|
168
|
+
"spawn_cwd": str(cwd),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def _base_command(self, agent: dict[str, Any], mcp_config: dict[str, Any]) -> list[str]:
|
|
172
|
+
prompt = compile_system_prompt(agent)
|
|
173
|
+
cmd = ["claude"]
|
|
174
|
+
if agent.get("_runtime", {}).get("dangerous_auto_approve"):
|
|
175
|
+
cmd.append("--dangerously-skip-permissions")
|
|
176
|
+
else:
|
|
177
|
+
cmd.extend(["--permission-mode", "default"])
|
|
178
|
+
model = agent_model(agent)
|
|
179
|
+
if model:
|
|
180
|
+
cmd.extend(["--model", model])
|
|
181
|
+
if prompt:
|
|
182
|
+
cmd.extend(["--append-system-prompt", prompt])
|
|
183
|
+
if mcp_config:
|
|
184
|
+
managed_compatible_config = (
|
|
185
|
+
agent.get("auth_mode") == "compatible_api"
|
|
186
|
+
and bool(agent.get("_provider_profile", {}).get("claude_projects_root"))
|
|
187
|
+
)
|
|
188
|
+
if not managed_compatible_config:
|
|
189
|
+
cmd.extend(["--mcp-config", json.dumps({"mcpServers": mcp_config})])
|
|
190
|
+
cmd.append("--strict-mcp-config")
|
|
191
|
+
allowed = set(resolve_permissions(agent)["tools"])
|
|
192
|
+
disallowed = claude_disallowed_tools(allowed)
|
|
193
|
+
for tool in disallowed:
|
|
194
|
+
cmd.extend(["--disallowedTools", tool])
|
|
195
|
+
return cmd
|
|
196
|
+
|
|
197
|
+
def auth_hint(self) -> dict[str, Any]:
|
|
198
|
+
if not self.is_installed():
|
|
199
|
+
return {"status": "missing", "detail": "claude command not found"}
|
|
200
|
+
try:
|
|
201
|
+
proc = subprocess.run(
|
|
202
|
+
["claude", "auth", "status"],
|
|
203
|
+
text=True,
|
|
204
|
+
capture_output=True,
|
|
205
|
+
timeout=8,
|
|
206
|
+
check=False,
|
|
207
|
+
)
|
|
208
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
209
|
+
return {"status": "missing_or_unknown", "detail": f"claude auth status failed: {exc}"}
|
|
210
|
+
text = (proc.stdout or proc.stderr).strip()
|
|
211
|
+
try:
|
|
212
|
+
status = json.loads(text) if text else {}
|
|
213
|
+
except json.JSONDecodeError:
|
|
214
|
+
status = {}
|
|
215
|
+
if status.get("loggedIn") is True or proc.returncode == 0:
|
|
216
|
+
method = status.get("authMethod") or "configured"
|
|
217
|
+
return {"status": "present", "detail": f"claude auth status ok: {method}"}
|
|
218
|
+
return {"status": "missing", "detail": text or "run claude auth login or claude setup-token"}
|
|
219
|
+
|
|
220
|
+
def status_patterns(self) -> dict[str, str]:
|
|
221
|
+
return {"idle": r"[>❯]\s", "processing": r"[✶✢✽✻✳·].*…", "error": "Error|Traceback"}
|
|
222
|
+
|
|
223
|
+
def handle_startup_prompts(
|
|
224
|
+
self,
|
|
225
|
+
session_name: str,
|
|
226
|
+
window_name: str,
|
|
227
|
+
checks: int = 30,
|
|
228
|
+
sleep_s: float = 0.5,
|
|
229
|
+
) -> list[dict[str, Any]]:
|
|
230
|
+
handled: list[dict[str, Any]] = []
|
|
231
|
+
target = f"{session_name}:{window_name}"
|
|
232
|
+
for _ in range(max(checks, 0)):
|
|
233
|
+
proc = subprocess.run(
|
|
234
|
+
["tmux", "capture-pane", "-p", "-S", "-", "-t", target],
|
|
235
|
+
text=True,
|
|
236
|
+
capture_output=True,
|
|
237
|
+
timeout=5,
|
|
238
|
+
check=False,
|
|
239
|
+
)
|
|
240
|
+
output = proc.stdout if proc.returncode == 0 else ""
|
|
241
|
+
if "Quick safety check" in output or "Yes, I trust this folder" in output:
|
|
242
|
+
subprocess.run(["tmux", "send-keys", "-t", target, "Enter"], check=False)
|
|
243
|
+
handled.append({"prompt": "claude_workspace_trust", "action": "sent_enter"})
|
|
244
|
+
break
|
|
245
|
+
if "Claude Code" in output and ("❯" in output or ">" in output):
|
|
246
|
+
break
|
|
247
|
+
if sleep_s > 0:
|
|
248
|
+
time.sleep(sleep_s)
|
|
249
|
+
return handled
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def claude_disallowed_tools(allowed: set[str]) -> list[str]:
|
|
253
|
+
mapping = {
|
|
254
|
+
"execute_bash": ["Bash"],
|
|
255
|
+
"fs_read": ["Read"],
|
|
256
|
+
"fs_write": ["Edit", "Write", "MultiEdit", "NotebookEdit"],
|
|
257
|
+
"fs_list": ["Glob", "Grep"],
|
|
258
|
+
}
|
|
259
|
+
disallowed: list[str] = []
|
|
260
|
+
for canonical, native in mapping.items():
|
|
261
|
+
if canonical not in allowed:
|
|
262
|
+
disallowed.extend(native)
|
|
263
|
+
return disallowed
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def find_claude_transcript(
|
|
267
|
+
root: Path,
|
|
268
|
+
cwd: Path,
|
|
269
|
+
spawn_time: datetime,
|
|
270
|
+
*,
|
|
271
|
+
agent_id: str,
|
|
272
|
+
predetermined_session_id: str | None,
|
|
273
|
+
exclude_session_ids: set[str] | None = None,
|
|
274
|
+
allow_older: bool = False,
|
|
275
|
+
require_agent_match: bool = False,
|
|
276
|
+
require_cwd: bool = True,
|
|
277
|
+
) -> dict[str, Any] | None:
|
|
278
|
+
if not root.exists():
|
|
279
|
+
return None
|
|
280
|
+
exclude_session_ids = exclude_session_ids or set()
|
|
281
|
+
if predetermined_session_id and predetermined_session_id not in exclude_session_ids:
|
|
282
|
+
for path in claude_transcript_paths(root, cwd, predetermined_session_id):
|
|
283
|
+
meta = read_claude_transcript_meta(path, cwd)
|
|
284
|
+
if meta and (not require_cwd or meta.get("same_cwd")) and meta.get("has_user_message"):
|
|
285
|
+
return {
|
|
286
|
+
"session_id": str(predetermined_session_id),
|
|
287
|
+
"rollout_path": str(path),
|
|
288
|
+
"timestamp": meta.get("timestamp") or datetime.fromtimestamp(path.stat().st_mtime, timezone.utc),
|
|
289
|
+
"captured_via": "fs_watch",
|
|
290
|
+
"confidence": "high",
|
|
291
|
+
}
|
|
292
|
+
lower_bound = spawn_time - timedelta(seconds=2)
|
|
293
|
+
upper_bound = datetime.now(timezone.utc) + timedelta(seconds=5)
|
|
294
|
+
candidates: list[dict[str, Any]] = []
|
|
295
|
+
for directory in claude_project_dirs(root, cwd):
|
|
296
|
+
for path in sorted(directory.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)[:300]:
|
|
297
|
+
meta = read_claude_transcript_meta(path, cwd)
|
|
298
|
+
if not meta or not meta.get("has_user_message"):
|
|
299
|
+
continue
|
|
300
|
+
if require_cwd and not meta.get("same_cwd"):
|
|
301
|
+
continue
|
|
302
|
+
session_id = str(meta.get("session_id") or path.stem)
|
|
303
|
+
if session_id in exclude_session_ids:
|
|
304
|
+
continue
|
|
305
|
+
ts = meta.get("timestamp") or datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)
|
|
306
|
+
if not allow_older and (ts < lower_bound or ts > upper_bound):
|
|
307
|
+
continue
|
|
308
|
+
text = str(meta.get("text") or "")
|
|
309
|
+
score = claude_agent_match_score(agent_id, text)
|
|
310
|
+
if require_agent_match and score < 2:
|
|
311
|
+
continue
|
|
312
|
+
if score <= 0 and not allow_older:
|
|
313
|
+
continue
|
|
314
|
+
candidates.append(
|
|
315
|
+
{
|
|
316
|
+
"session_id": session_id,
|
|
317
|
+
"rollout_path": str(path),
|
|
318
|
+
"timestamp": ts,
|
|
319
|
+
"captured_via": "fs_watch",
|
|
320
|
+
"confidence": "high" if score >= 2 else "medium",
|
|
321
|
+
"score": score,
|
|
322
|
+
}
|
|
323
|
+
)
|
|
324
|
+
if not candidates:
|
|
325
|
+
return None
|
|
326
|
+
candidates.sort(key=lambda item: (item["score"], item["timestamp"]), reverse=True)
|
|
327
|
+
return candidates[0]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def claude_project_dirs(root: Path, cwd: Path) -> list[Path]:
|
|
331
|
+
return [directory for directory in _unique_paths([claude_project_dir(root, cwd), claude_legacy_project_dir(root, cwd)]) if directory.exists()]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def claude_project_dir(root: Path, cwd: Path) -> Path:
|
|
335
|
+
try:
|
|
336
|
+
cwd_text = str(cwd.resolve())
|
|
337
|
+
except OSError:
|
|
338
|
+
cwd_text = str(cwd)
|
|
339
|
+
return root / re.sub(r"[^A-Za-z0-9.-]", "-", cwd_text)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def claude_legacy_project_dir(root: Path, cwd: Path) -> Path:
|
|
343
|
+
try:
|
|
344
|
+
cwd_text = str(cwd.resolve())
|
|
345
|
+
except OSError:
|
|
346
|
+
cwd_text = str(cwd)
|
|
347
|
+
return root / re.sub(r"[^A-Za-z0-9._-]", "-", cwd_text)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def claude_transcript_path(root: Path, cwd: Path, session_id: str) -> Path:
|
|
351
|
+
return claude_project_dir(root, cwd) / f"{session_id}.jsonl"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def claude_transcript_paths(root: Path, cwd: Path, session_id: str) -> list[Path]:
|
|
355
|
+
if not session_id:
|
|
356
|
+
return []
|
|
357
|
+
return _unique_paths(
|
|
358
|
+
[
|
|
359
|
+
claude_project_dir(root, cwd) / f"{session_id}.jsonl",
|
|
360
|
+
claude_legacy_project_dir(root, cwd) / f"{session_id}.jsonl",
|
|
361
|
+
]
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _unique_paths(paths: list[Path]) -> list[Path]:
|
|
366
|
+
seen: set[str] = set()
|
|
367
|
+
result: list[Path] = []
|
|
368
|
+
for path in paths:
|
|
369
|
+
key = str(path)
|
|
370
|
+
if key in seen:
|
|
371
|
+
continue
|
|
372
|
+
seen.add(key)
|
|
373
|
+
result.append(path)
|
|
374
|
+
return result
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def read_claude_transcript_meta(path: Path, cwd: Path | None = None) -> dict[str, Any] | None:
|
|
378
|
+
if not path.exists():
|
|
379
|
+
return None
|
|
380
|
+
session_id: str | None = None
|
|
381
|
+
transcript_cwd: str | None = None
|
|
382
|
+
timestamp: datetime | None = None
|
|
383
|
+
has_user_message = False
|
|
384
|
+
text_parts: list[str] = []
|
|
385
|
+
try:
|
|
386
|
+
with path.open(encoding="utf-8") as handle:
|
|
387
|
+
for index, line in enumerate(handle):
|
|
388
|
+
if index >= 200:
|
|
389
|
+
break
|
|
390
|
+
try:
|
|
391
|
+
data = json.loads(line)
|
|
392
|
+
except json.JSONDecodeError:
|
|
393
|
+
continue
|
|
394
|
+
if not session_id and data.get("sessionId"):
|
|
395
|
+
session_id = str(data.get("sessionId"))
|
|
396
|
+
if not transcript_cwd and data.get("cwd"):
|
|
397
|
+
transcript_cwd = str(data.get("cwd"))
|
|
398
|
+
timestamp = timestamp or parse_time(data.get("timestamp"))
|
|
399
|
+
if data.get("type") == "user":
|
|
400
|
+
text = claude_message_text(data.get("message", {}).get("content"))
|
|
401
|
+
if text.strip():
|
|
402
|
+
has_user_message = True
|
|
403
|
+
if sum(len(part) for part in text_parts) < 8000:
|
|
404
|
+
text_parts.append(text[:4000])
|
|
405
|
+
except OSError:
|
|
406
|
+
return None
|
|
407
|
+
same_cwd = True
|
|
408
|
+
if cwd is not None:
|
|
409
|
+
same_cwd = _same_path(transcript_cwd, cwd)
|
|
410
|
+
return {
|
|
411
|
+
"session_id": session_id or path.stem,
|
|
412
|
+
"cwd": transcript_cwd,
|
|
413
|
+
"same_cwd": same_cwd,
|
|
414
|
+
"timestamp": timestamp,
|
|
415
|
+
"has_user_message": has_user_message,
|
|
416
|
+
"text": "\n".join(text_parts),
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def claude_message_text(content: Any) -> str:
|
|
421
|
+
if isinstance(content, str):
|
|
422
|
+
return content
|
|
423
|
+
if isinstance(content, list):
|
|
424
|
+
parts: list[str] = []
|
|
425
|
+
for item in content:
|
|
426
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
427
|
+
parts.append(item["text"])
|
|
428
|
+
elif isinstance(item, dict) and isinstance(item.get("content"), str):
|
|
429
|
+
parts.append(item["content"])
|
|
430
|
+
return "\n".join(parts)
|
|
431
|
+
return ""
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def claude_agent_match_score(agent_id: str, text: str) -> int:
|
|
435
|
+
if not agent_id:
|
|
436
|
+
return 0
|
|
437
|
+
lowered = text.lower()
|
|
438
|
+
agent = agent_id.lower()
|
|
439
|
+
score = 0
|
|
440
|
+
if f"agents/{agent}.md" in lowered or f"agents\\/{agent}.md" in lowered:
|
|
441
|
+
score += 1
|
|
442
|
+
if f"team_agent_id={agent}" in lowered or f"team_agent_id={agent_id}" in text:
|
|
443
|
+
score += 2
|
|
444
|
+
if f"your agent id: {agent}" in lowered:
|
|
445
|
+
score += 2
|
|
446
|
+
if f"team agent worker {agent}" in lowered or f"worker `{agent}`" in lowered:
|
|
447
|
+
score += 2
|
|
448
|
+
return score
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _same_path(value: str | None, path: Path) -> bool:
|
|
452
|
+
if not value:
|
|
453
|
+
return True
|
|
454
|
+
try:
|
|
455
|
+
return Path(value).resolve() == path.resolve()
|
|
456
|
+
except OSError:
|
|
457
|
+
return str(value) == str(path)
|