git-juggler 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- {git_juggler-0.1.2 → git_juggler-0.1.3}/PKG-INFO +1 -1
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/__init__.py +7 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/activity_tracker.py +34 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/agent_activity_adapter.py +17 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/agent_process_discovery.py +60 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/agent_repository_tracker.py +206 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/create_process_inspector.py +15 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/git_resolver.py +89 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/linux_process_inspector.py +130 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/mac_process_inspector.py +122 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/paths.py +7 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/process_info.py +65 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/process_inspector.py +16 -0
- git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/worktree_evidence.py +167 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/app.py +70 -5
- git_juggler-0.1.3/src/backend/git_juggler/browse.py +27 -0
- git_juggler-0.1.3/src/backend/git_juggler/ci.py +26 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/config.py +50 -0
- git_juggler-0.1.3/src/backend/git_juggler/frontend_dist/assets/index-DrLGhTYA.css +1 -0
- git_juggler-0.1.3/src/backend/git_juggler/frontend_dist/assets/index-DxTmj1Il.js +20 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/frontend_dist/index.html +2 -2
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/github_actions.py +10 -8
- git_juggler-0.1.3/src/backend/git_juggler/jenkins.py +215 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/repos.py +12 -1
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/schemas.py +97 -4
- git_juggler-0.1.3/src/backend/tests/test_agent_tracking.py +428 -0
- git_juggler-0.1.3/src/backend/tests/test_jenkins.py +74 -0
- git_juggler-0.1.3/src/backend/tests/test_repos.py +40 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/App.css +465 -52
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/App.tsx +8 -3
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/api/client.ts +16 -3
- git_juggler-0.1.3/src/frontend/src/api/types.ts +204 -0
- git_juggler-0.1.3/src/frontend/src/components/Agents/AgentActivityPanel.tsx +106 -0
- git_juggler-0.1.2/src/frontend/src/components/Badges/GitHubActionsBadge.tsx → git_juggler-0.1.3/src/frontend/src/components/Badges/CiRunBadge.tsx +38 -26
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/CommitRow.tsx +5 -5
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/BranchContextMenu.tsx +9 -6
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/CommitContextMenu.tsx +82 -6
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/RepoContextMenu.tsx +9 -6
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/TagContextMenu.tsx +9 -6
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Graph/GraphPanel.tsx +77 -2
- git_juggler-0.1.3/src/frontend/src/components/Menu/DirectoryBrowserModal.tsx +44 -0
- git_juggler-0.1.3/src/frontend/src/components/Menu/MainMenu.tsx +597 -0
- git_juggler-0.1.3/src/frontend/src/components/Sidebar/RepoList.tsx +586 -0
- git_juggler-0.1.3/src/frontend/src/components/Tabs/TabsBar.tsx +134 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Terminal/TerminalPanel.tsx +2 -2
- git_juggler-0.1.3/src/frontend/src/lib/dismissOnOutsideClick.ts +20 -0
- git_juggler-0.1.3/src/frontend/src/lib/flip.ts +21 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/state/store.ts +421 -54
- git_juggler-0.1.2/src/backend/git_juggler/frontend_dist/assets/index-Br0FgpWw.css +0 -1
- git_juggler-0.1.2/src/backend/git_juggler/frontend_dist/assets/index-Cc_reM34.js +0 -17
- git_juggler-0.1.2/src/frontend/src/api/types.ts +0 -109
- git_juggler-0.1.2/src/frontend/src/components/Menu/MainMenu.tsx +0 -280
- git_juggler-0.1.2/src/frontend/src/components/Sidebar/RepoList.tsx +0 -88
- git_juggler-0.1.2/src/frontend/src/components/Tabs/TabsBar.tsx +0 -171
- {git_juggler-0.1.2 → git_juggler-0.1.3}/.github/workflows/build-package.yml +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/.gitignore +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/CLAUDE.md +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/LICENSE +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/README.md +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/notes.txt +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/plans/github-actions-settings-and-build-status.md +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/plans/jenkins-settings-and-build-status.md +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/pyproject.toml +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/scripts/build-package.sh +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/__init__.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/commit_detail.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/dev_app.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/frontend_dist/favicon.svg +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/frontend_dist/icons.svg +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/git_data.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/git_utils.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/main.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/terminal.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/tests/test_git_data.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/tests/test_github_actions.py +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/.gitignore +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/README.md +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/index.html +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/package-lock.json +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/package.json +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/public/favicon.svg +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/public/icons.svg +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/BranchBadge.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/StashBadge.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/TagBadge.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/icons.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Branches/DeleteBranchModal.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/CommitDetail.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/CommitList.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/UncommittedRow.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/CreateTagModal.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Graph/branchColor.ts +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Graph/computeColumns.ts +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Search/MultiSelect.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Search/SearchBox.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Tags/DeleteTagModal.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/index.css +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/index.tsx +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/formatDate.ts +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/scrollToCommit.ts +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/stripAnsi.ts +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/terminalTheme.ts +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/tsconfig.app.json +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/tsconfig.json +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/tsconfig.node.json +0 -0
- {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/vite.config.ts +0 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .activity_tracker import ActivityTracker
|
|
4
|
+
from .agent_repository_tracker import AgentRepositoryScan, AgentRepositoryTracker
|
|
5
|
+
from .process_info import ProcessInfo
|
|
6
|
+
|
|
7
|
+
__all__ = ["ActivityTracker", "AgentRepositoryScan", "AgentRepositoryTracker", "ProcessInfo"]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from .agent_repository_tracker import AgentRepositoryScan, AgentRepositoryTracker
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ActivityTracker:
|
|
10
|
+
def __init__(self, tracker: AgentRepositoryTracker | None = None, poll_interval_ms: int = 1000) -> None:
|
|
11
|
+
self.tracker = tracker or AgentRepositoryTracker()
|
|
12
|
+
self.poll_interval_ms = poll_interval_ms
|
|
13
|
+
self._stop_event = threading.Event()
|
|
14
|
+
self._thread: threading.Thread | None = None
|
|
15
|
+
|
|
16
|
+
def start(self, agent_pid: int, on_scan: Callable[[AgentRepositoryScan], None]) -> None:
|
|
17
|
+
if self._thread is not None and self._thread.is_alive():
|
|
18
|
+
return
|
|
19
|
+
self._stop_event.clear()
|
|
20
|
+
self._thread = threading.Thread(target=self._run, args=(agent_pid, on_scan), daemon=True)
|
|
21
|
+
self._thread.start()
|
|
22
|
+
|
|
23
|
+
def stop(self) -> None:
|
|
24
|
+
self._stop_event.set()
|
|
25
|
+
if self._thread is not None:
|
|
26
|
+
self._thread.join(timeout=max(1, self.poll_interval_ms / 1000 * 2))
|
|
27
|
+
self._thread = None
|
|
28
|
+
|
|
29
|
+
def _run(self, agent_pid: int, on_scan: Callable[[AgentRepositoryScan], None]) -> None:
|
|
30
|
+
interval_seconds = max(0.1, self.poll_interval_ms / 1000)
|
|
31
|
+
while not self._stop_event.is_set():
|
|
32
|
+
scan = self.tracker.scan(agent_pid)
|
|
33
|
+
on_scan(scan)
|
|
34
|
+
self._stop_event.wait(interval_seconds)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from .process_info import ProcessInfo
|
|
6
|
+
from .worktree_evidence import WorktreeEvidence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AgentActivityAdapter(Protocol):
|
|
10
|
+
def supports(self, process: ProcessInfo) -> bool:
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
def get_session_directory(self, process: ProcessInfo) -> str | None:
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def get_activity_evidence(self, process: ProcessInfo) -> list[WorktreeEvidence]:
|
|
17
|
+
...
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_AGENT_PATTERNS = ("claude", "opencode")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class AgentProcessCandidate:
|
|
14
|
+
pid: int
|
|
15
|
+
command_line: str
|
|
16
|
+
matched_pattern: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CommandRunner:
|
|
20
|
+
def run(self, args: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
21
|
+
return subprocess.run(args, capture_output=True, text=True, timeout=5, check=False) # noqa: S603 - fixed executable plus arg array
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_pgrep_output(output: str, pattern: str, current_pid: int | None = None) -> list[AgentProcessCandidate]:
|
|
25
|
+
candidates: list[AgentProcessCandidate] = []
|
|
26
|
+
for line in output.splitlines():
|
|
27
|
+
stripped = line.strip()
|
|
28
|
+
if not stripped:
|
|
29
|
+
continue
|
|
30
|
+
pid_text, _, command_line = stripped.partition(" ")
|
|
31
|
+
try:
|
|
32
|
+
pid = int(pid_text)
|
|
33
|
+
except ValueError:
|
|
34
|
+
continue
|
|
35
|
+
if current_pid is not None and pid == current_pid:
|
|
36
|
+
continue
|
|
37
|
+
candidates.append(AgentProcessCandidate(pid=pid, command_line=command_line or pattern, matched_pattern=pattern))
|
|
38
|
+
return candidates
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AgentProcessDiscovery:
|
|
42
|
+
def __init__(self, patterns: Sequence[str] = DEFAULT_AGENT_PATTERNS, runner: CommandRunner | None = None) -> None:
|
|
43
|
+
self.patterns = tuple(pattern for pattern in patterns if pattern.strip())
|
|
44
|
+
self.runner = runner or CommandRunner()
|
|
45
|
+
|
|
46
|
+
def discover(self) -> list[AgentProcessCandidate]:
|
|
47
|
+
by_pid: dict[int, AgentProcessCandidate] = {}
|
|
48
|
+
for pattern in self.patterns:
|
|
49
|
+
for candidate in self._discover_pattern(pattern):
|
|
50
|
+
by_pid.setdefault(candidate.pid, candidate)
|
|
51
|
+
return sorted(by_pid.values(), key=lambda candidate: candidate.pid)
|
|
52
|
+
|
|
53
|
+
def _discover_pattern(self, pattern: str) -> list[AgentProcessCandidate]:
|
|
54
|
+
try:
|
|
55
|
+
result = self.runner.run(["pgrep", "-af", pattern])
|
|
56
|
+
except (OSError, subprocess.SubprocessError):
|
|
57
|
+
return []
|
|
58
|
+
if result.returncode not in (0, 1):
|
|
59
|
+
return []
|
|
60
|
+
return parse_pgrep_output(result.stdout, pattern, current_pid=os.getpid())
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from .agent_activity_adapter import AgentActivityAdapter
|
|
8
|
+
from .create_process_inspector import create_process_inspector
|
|
9
|
+
from .git_resolver import GitResolver, GitWorktreeInfo
|
|
10
|
+
from .process_info import ProcessInfo
|
|
11
|
+
from .process_inspector import ProcessInspector
|
|
12
|
+
from .worktree_evidence import WorktreeEvidence, command_path_evidence, cwd_evidence, file_access_evidence, session_directory_evidence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
WORKTREE_ACTIVITY_TTL_MS = 30_000
|
|
16
|
+
MIN_ACTIVE_WORKTREE_SCORE = 3
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ActivityEvidence:
|
|
21
|
+
type: str
|
|
22
|
+
pid: int | None = None
|
|
23
|
+
cwd: str | None = None
|
|
24
|
+
path: str | None = None
|
|
25
|
+
executable: str | None = None
|
|
26
|
+
command: str | None = None
|
|
27
|
+
process_role: str | None = None
|
|
28
|
+
tool: str | None = None
|
|
29
|
+
score: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class AgentWorktreeActivity:
|
|
34
|
+
repository_id: str
|
|
35
|
+
worktree_path: str
|
|
36
|
+
branch: str | None
|
|
37
|
+
commit: str
|
|
38
|
+
process_ids: list[int]
|
|
39
|
+
first_seen: int
|
|
40
|
+
last_seen: int
|
|
41
|
+
last_activity: int
|
|
42
|
+
evidence: list[ActivityEvidence] = field(default_factory=list)
|
|
43
|
+
activity_score: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class AgentRepositoryScan:
|
|
48
|
+
agent_pid: int
|
|
49
|
+
session_directory: str | None
|
|
50
|
+
processes: list[ProcessInfo]
|
|
51
|
+
worktrees: list[AgentWorktreeActivity]
|
|
52
|
+
scanned_at: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AgentRepositoryTracker:
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
process_inspector: ProcessInspector | None = None,
|
|
59
|
+
git_resolver: GitResolver | None = None,
|
|
60
|
+
adapters: Sequence[AgentActivityAdapter] = (),
|
|
61
|
+
worktree_activity_ttl_ms: int = WORKTREE_ACTIVITY_TTL_MS,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.process_inspector = process_inspector or create_process_inspector()
|
|
64
|
+
self.git_resolver = git_resolver or GitResolver()
|
|
65
|
+
self.adapters = tuple(adapters)
|
|
66
|
+
self.worktree_activity_ttl_ms = worktree_activity_ttl_ms
|
|
67
|
+
self._activities: dict[str, AgentWorktreeActivity] = {}
|
|
68
|
+
self._scan_running = False
|
|
69
|
+
|
|
70
|
+
def scan(self, agent_pid: int) -> AgentRepositoryScan:
|
|
71
|
+
if self._scan_running:
|
|
72
|
+
now = int(time.time() * 1000)
|
|
73
|
+
return AgentRepositoryScan(agent_pid=agent_pid, session_directory=None, processes=[], worktrees=self._retained(now), scanned_at=now)
|
|
74
|
+
|
|
75
|
+
self._scan_running = True
|
|
76
|
+
try:
|
|
77
|
+
return self._scan(agent_pid)
|
|
78
|
+
finally:
|
|
79
|
+
self._scan_running = False
|
|
80
|
+
|
|
81
|
+
def _scan(self, agent_pid: int) -> AgentRepositoryScan:
|
|
82
|
+
scanned_at = int(time.time() * 1000)
|
|
83
|
+
self.git_resolver.clear_cache()
|
|
84
|
+
|
|
85
|
+
root = self.process_inspector.get_process(agent_pid)
|
|
86
|
+
descendants = self.process_inspector.get_descendants(agent_pid) if root is not None else []
|
|
87
|
+
processes = ([root] if root is not None else []) + descendants
|
|
88
|
+
child_depths = self._child_depths(agent_pid, descendants)
|
|
89
|
+
session_directory = self._session_directory(root) if root is not None else None
|
|
90
|
+
|
|
91
|
+
current = self._collect_current_activities(agent_pid, processes, child_depths, scanned_at)
|
|
92
|
+
for key, activity in current.items():
|
|
93
|
+
existing = self._activities.get(key)
|
|
94
|
+
if existing is not None:
|
|
95
|
+
activity.first_seen = existing.first_seen
|
|
96
|
+
self._activities[key] = activity
|
|
97
|
+
|
|
98
|
+
self._expire(scanned_at)
|
|
99
|
+
return AgentRepositoryScan(
|
|
100
|
+
agent_pid=agent_pid,
|
|
101
|
+
session_directory=session_directory,
|
|
102
|
+
processes=processes,
|
|
103
|
+
worktrees=sorted(self._activities.values(), key=lambda item: item.worktree_path),
|
|
104
|
+
scanned_at=scanned_at,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _collect_current_activities(self, root_pid: int, processes: list[ProcessInfo], child_depths: dict[int, int], observed_at: int) -> dict[str, AgentWorktreeActivity]:
|
|
108
|
+
activities: dict[str, AgentWorktreeActivity] = {}
|
|
109
|
+
for process in processes:
|
|
110
|
+
for evidence in self._path_evidence_for_process(process, root_pid, child_depths):
|
|
111
|
+
worktree = self.git_resolver.resolve_path(evidence.path)
|
|
112
|
+
if worktree is None:
|
|
113
|
+
continue
|
|
114
|
+
activity = activities.get(worktree.worktree_path)
|
|
115
|
+
if activity is None:
|
|
116
|
+
activity = self._new_activity(worktree, observed_at)
|
|
117
|
+
activities[worktree.worktree_path] = activity
|
|
118
|
+
if process.pid not in activity.process_ids and evidence.pid is not None:
|
|
119
|
+
activity.process_ids.append(process.pid)
|
|
120
|
+
self._add_evidence(activity, evidence)
|
|
121
|
+
activity.last_seen = observed_at
|
|
122
|
+
if evidence.score >= MIN_ACTIVE_WORKTREE_SCORE:
|
|
123
|
+
activity.last_activity = observed_at
|
|
124
|
+
|
|
125
|
+
active = {key: activity for key, activity in activities.items() if activity.activity_score >= MIN_ACTIVE_WORKTREE_SCORE}
|
|
126
|
+
for activity in active.values():
|
|
127
|
+
activity.process_ids.sort()
|
|
128
|
+
return active
|
|
129
|
+
|
|
130
|
+
def _path_evidence_for_process(self, process: ProcessInfo, root_pid: int, child_depths: dict[int, int]) -> list[WorktreeEvidence]:
|
|
131
|
+
evidence: list[WorktreeEvidence] = []
|
|
132
|
+
command_evidence = command_path_evidence(process, root_pid, child_depths)
|
|
133
|
+
file_evidence = file_access_evidence(process, root_pid, child_depths)
|
|
134
|
+
if process.pid == root_pid:
|
|
135
|
+
session_evidence = session_directory_evidence(process)
|
|
136
|
+
if session_evidence is not None:
|
|
137
|
+
evidence.append(session_evidence)
|
|
138
|
+
process_cwd_evidence = cwd_evidence(process, root_pid, child_depths)
|
|
139
|
+
if process_cwd_evidence is not None and not command_evidence:
|
|
140
|
+
evidence.append(process_cwd_evidence)
|
|
141
|
+
evidence.extend(command_evidence)
|
|
142
|
+
evidence.extend(file_evidence)
|
|
143
|
+
for adapter in self._supported_adapters(process):
|
|
144
|
+
evidence.extend(adapter.get_activity_evidence(process))
|
|
145
|
+
return evidence
|
|
146
|
+
|
|
147
|
+
def _session_directory(self, process: ProcessInfo) -> str | None:
|
|
148
|
+
for adapter in self._supported_adapters(process):
|
|
149
|
+
session_directory = adapter.get_session_directory(process)
|
|
150
|
+
if session_directory:
|
|
151
|
+
return session_directory
|
|
152
|
+
return process.cwd
|
|
153
|
+
|
|
154
|
+
def _supported_adapters(self, process: ProcessInfo) -> list[AgentActivityAdapter]:
|
|
155
|
+
return [adapter for adapter in self.adapters if adapter.supports(process)]
|
|
156
|
+
|
|
157
|
+
def _child_depths(self, root_pid: int, descendants: list[ProcessInfo]) -> dict[int, int]:
|
|
158
|
+
by_pid = {process.pid: process for process in descendants}
|
|
159
|
+
depths: dict[int, int] = {}
|
|
160
|
+
for process in descendants:
|
|
161
|
+
depth = 1
|
|
162
|
+
cursor = process
|
|
163
|
+
seen: set[int] = set()
|
|
164
|
+
while cursor.parent_pid is not None and cursor.parent_pid != root_pid and cursor.parent_pid in by_pid and cursor.parent_pid not in seen:
|
|
165
|
+
seen.add(cursor.parent_pid)
|
|
166
|
+
depth += 1
|
|
167
|
+
cursor = by_pid[cursor.parent_pid]
|
|
168
|
+
depths[process.pid] = depth
|
|
169
|
+
return depths
|
|
170
|
+
|
|
171
|
+
def _new_activity(self, worktree: GitWorktreeInfo, observed_at: int) -> AgentWorktreeActivity:
|
|
172
|
+
return AgentWorktreeActivity(
|
|
173
|
+
repository_id=worktree.repository_id,
|
|
174
|
+
worktree_path=worktree.worktree_path,
|
|
175
|
+
branch=worktree.branch,
|
|
176
|
+
commit=worktree.commit,
|
|
177
|
+
process_ids=[],
|
|
178
|
+
first_seen=observed_at,
|
|
179
|
+
last_seen=observed_at,
|
|
180
|
+
last_activity=observed_at,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _add_evidence(self, activity: AgentWorktreeActivity, evidence: WorktreeEvidence) -> None:
|
|
184
|
+
activity.evidence.append(
|
|
185
|
+
ActivityEvidence(
|
|
186
|
+
type=evidence.type,
|
|
187
|
+
pid=evidence.pid,
|
|
188
|
+
cwd=evidence.path if evidence.type.endswith("cwd") else None,
|
|
189
|
+
path=evidence.path,
|
|
190
|
+
executable=evidence.executable,
|
|
191
|
+
command=evidence.command,
|
|
192
|
+
process_role=evidence.process_role,
|
|
193
|
+
tool=evidence.tool,
|
|
194
|
+
score=evidence.score,
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
activity.activity_score += evidence.score
|
|
198
|
+
|
|
199
|
+
def _expire(self, now: int) -> None:
|
|
200
|
+
expired = [key for key, activity in self._activities.items() if now - activity.last_activity > self.worktree_activity_ttl_ms]
|
|
201
|
+
for key in expired:
|
|
202
|
+
del self._activities[key]
|
|
203
|
+
|
|
204
|
+
def _retained(self, now: int) -> list[AgentWorktreeActivity]:
|
|
205
|
+
self._expire(now)
|
|
206
|
+
return sorted(self._activities.values(), key=lambda item: item.worktree_path)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from .linux_process_inspector import LinuxProcessInspector
|
|
6
|
+
from .mac_process_inspector import MacProcessInspector
|
|
7
|
+
from .process_inspector import ProcessInspector
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_process_inspector() -> ProcessInspector:
|
|
11
|
+
if sys.platform == "darwin":
|
|
12
|
+
return MacProcessInspector()
|
|
13
|
+
if sys.platform.startswith("linux"):
|
|
14
|
+
return LinuxProcessInspector()
|
|
15
|
+
raise RuntimeError(f"unsupported platform for process inspection: {sys.platform}")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .paths import normalize_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class GitWorktreeInfo:
|
|
13
|
+
repository_id: str
|
|
14
|
+
worktree_path: str
|
|
15
|
+
common_git_dir: str
|
|
16
|
+
branch: str | None
|
|
17
|
+
commit: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitCommandRunner:
|
|
21
|
+
def run(self, args: Sequence[str]) -> subprocess.CompletedProcess[str]:
|
|
22
|
+
return subprocess.run(args, capture_output=True, text=True, timeout=5, check=False) # noqa: S603 - fixed executable plus arg array
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GitResolver:
|
|
26
|
+
def __init__(self, runner: GitCommandRunner | None = None) -> None:
|
|
27
|
+
self.runner = runner or GitCommandRunner()
|
|
28
|
+
self._cache: dict[str, GitWorktreeInfo | None] = {}
|
|
29
|
+
|
|
30
|
+
def clear_cache(self) -> None:
|
|
31
|
+
self._cache.clear()
|
|
32
|
+
|
|
33
|
+
def resolve_directory(self, directory: str | Path) -> GitWorktreeInfo | None:
|
|
34
|
+
try:
|
|
35
|
+
key = normalize_path(directory)
|
|
36
|
+
except OSError:
|
|
37
|
+
return None
|
|
38
|
+
if key not in self._cache:
|
|
39
|
+
self._cache[key] = self._resolve_uncached(Path(key))
|
|
40
|
+
return self._cache[key]
|
|
41
|
+
|
|
42
|
+
def resolve_path(self, path: str | Path) -> GitWorktreeInfo | None:
|
|
43
|
+
try:
|
|
44
|
+
candidate = Path(path).expanduser().resolve()
|
|
45
|
+
except OSError:
|
|
46
|
+
return None
|
|
47
|
+
if candidate.is_file():
|
|
48
|
+
candidate = candidate.parent
|
|
49
|
+
while True:
|
|
50
|
+
resolved = self.resolve_directory(candidate)
|
|
51
|
+
if resolved is not None:
|
|
52
|
+
return resolved
|
|
53
|
+
parent = candidate.parent
|
|
54
|
+
if parent == candidate:
|
|
55
|
+
return None
|
|
56
|
+
candidate = parent
|
|
57
|
+
|
|
58
|
+
def _resolve_uncached(self, directory: Path) -> GitWorktreeInfo | None:
|
|
59
|
+
top_level = self._git(directory, "rev-parse", "--show-toplevel")
|
|
60
|
+
common_git_dir = self._git(directory, "rev-parse", "--git-common-dir")
|
|
61
|
+
commit = self._git(directory, "rev-parse", "HEAD")
|
|
62
|
+
if top_level is None or common_git_dir is None or commit is None:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
branch = self._git(directory, "branch", "--show-current")
|
|
66
|
+
normalized_common = self._normalize_git_path(directory, common_git_dir)
|
|
67
|
+
normalized_top = normalize_path(top_level)
|
|
68
|
+
return GitWorktreeInfo(
|
|
69
|
+
repository_id=normalized_common,
|
|
70
|
+
worktree_path=normalized_top,
|
|
71
|
+
common_git_dir=normalized_common,
|
|
72
|
+
branch=branch if branch else None,
|
|
73
|
+
commit=commit,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _git(self, directory: Path, *args: str) -> str | None:
|
|
77
|
+
try:
|
|
78
|
+
result = self.runner.run(["git", "-C", str(directory), *args])
|
|
79
|
+
except (OSError, subprocess.SubprocessError):
|
|
80
|
+
return None
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
return None
|
|
83
|
+
return result.stdout.strip()
|
|
84
|
+
|
|
85
|
+
def _normalize_git_path(self, directory: Path, raw_path: str) -> str:
|
|
86
|
+
path = Path(raw_path)
|
|
87
|
+
if not path.is_absolute():
|
|
88
|
+
path = directory / path
|
|
89
|
+
return normalize_path(path)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .process_info import ProcessInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PROC_ROOT = Path("/proc")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse_proc_stat_ppid(stat: str) -> int | None:
|
|
13
|
+
close = stat.rfind(")")
|
|
14
|
+
if close < 0:
|
|
15
|
+
return None
|
|
16
|
+
rest = stat[close + 1 :].strip().split()
|
|
17
|
+
if len(rest) < 2:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return int(rest[1])
|
|
21
|
+
except ValueError:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_proc_cmdline(raw: bytes) -> str | None:
|
|
26
|
+
parts = parse_proc_cmdline_args(raw)
|
|
27
|
+
return " ".join(parts) if parts else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_proc_cmdline_args(raw: bytes) -> list[str]:
|
|
31
|
+
parts = [part.decode("utf-8", errors="replace") for part in raw.split(b"\0") if part]
|
|
32
|
+
return parts
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LinuxProcessInspector:
|
|
36
|
+
def __init__(self, proc_root: Path = PROC_ROOT) -> None:
|
|
37
|
+
self.proc_root = proc_root
|
|
38
|
+
|
|
39
|
+
def get_process(self, pid: int) -> ProcessInfo | None:
|
|
40
|
+
return self._read_process(pid)
|
|
41
|
+
|
|
42
|
+
def get_children(self, pid: int) -> list[ProcessInfo]:
|
|
43
|
+
return [process for process in self._snapshot().values() if process.parent_pid == pid]
|
|
44
|
+
|
|
45
|
+
def get_descendants(self, pid: int) -> list[ProcessInfo]:
|
|
46
|
+
snapshot = self._snapshot()
|
|
47
|
+
children_by_parent: dict[int, list[ProcessInfo]] = {}
|
|
48
|
+
for process in snapshot.values():
|
|
49
|
+
if process.parent_pid is not None:
|
|
50
|
+
children_by_parent.setdefault(process.parent_pid, []).append(process)
|
|
51
|
+
|
|
52
|
+
descendants: list[ProcessInfo] = []
|
|
53
|
+
stack = list(children_by_parent.get(pid, []))
|
|
54
|
+
seen: set[int] = set()
|
|
55
|
+
while stack:
|
|
56
|
+
process = stack.pop()
|
|
57
|
+
if process.pid in seen:
|
|
58
|
+
continue
|
|
59
|
+
seen.add(process.pid)
|
|
60
|
+
descendants.append(process)
|
|
61
|
+
stack.extend(children_by_parent.get(process.pid, []))
|
|
62
|
+
return descendants
|
|
63
|
+
|
|
64
|
+
def _snapshot(self) -> dict[int, ProcessInfo]:
|
|
65
|
+
processes: dict[int, ProcessInfo] = {}
|
|
66
|
+
try:
|
|
67
|
+
entries = list(os.scandir(self.proc_root))
|
|
68
|
+
except OSError:
|
|
69
|
+
return processes
|
|
70
|
+
for entry in entries:
|
|
71
|
+
if not entry.name.isdigit():
|
|
72
|
+
continue
|
|
73
|
+
process = self._read_process(int(entry.name))
|
|
74
|
+
if process is not None:
|
|
75
|
+
processes[process.pid] = process
|
|
76
|
+
return processes
|
|
77
|
+
|
|
78
|
+
def _read_process(self, pid: int) -> ProcessInfo | None:
|
|
79
|
+
base = self.proc_root / str(pid)
|
|
80
|
+
parent_pid = self._read_parent_pid(base)
|
|
81
|
+
if parent_pid is None:
|
|
82
|
+
return None
|
|
83
|
+
return ProcessInfo(
|
|
84
|
+
pid=pid,
|
|
85
|
+
parent_pid=parent_pid,
|
|
86
|
+
executable=self._readlink_or_none(base / "exe"),
|
|
87
|
+
command_line=self._read_cmdline(base / "cmdline"),
|
|
88
|
+
arguments=self._read_cmdline_args(base / "cmdline"),
|
|
89
|
+
open_files=self._read_open_files(base / "fd"),
|
|
90
|
+
cwd=self._readlink_or_none(base / "cwd"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _read_parent_pid(self, base: Path) -> int | None:
|
|
94
|
+
try:
|
|
95
|
+
return parse_proc_stat_ppid((base / "stat").read_text(encoding="utf-8", errors="replace"))
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
def _read_cmdline(self, path: Path) -> str | None:
|
|
100
|
+
try:
|
|
101
|
+
return parse_proc_cmdline(path.read_bytes())
|
|
102
|
+
except OSError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def _read_cmdline_args(self, path: Path) -> list[str] | None:
|
|
106
|
+
try:
|
|
107
|
+
return parse_proc_cmdline_args(path.read_bytes())
|
|
108
|
+
except OSError:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def _readlink_or_none(self, path: Path) -> str | None:
|
|
112
|
+
try:
|
|
113
|
+
return os.readlink(path)
|
|
114
|
+
except OSError:
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
def _read_open_files(self, fd_dir: Path) -> list[str]:
|
|
118
|
+
paths: list[str] = []
|
|
119
|
+
try:
|
|
120
|
+
entries = list(os.scandir(fd_dir))
|
|
121
|
+
except OSError:
|
|
122
|
+
return paths
|
|
123
|
+
for entry in entries:
|
|
124
|
+
try:
|
|
125
|
+
target = os.readlink(entry.path)
|
|
126
|
+
except OSError:
|
|
127
|
+
continue
|
|
128
|
+
if target.startswith("/"):
|
|
129
|
+
paths.append(target)
|
|
130
|
+
return list(dict.fromkeys(paths))
|