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.
Files changed (106) hide show
  1. {git_juggler-0.1.2 → git_juggler-0.1.3}/PKG-INFO +1 -1
  2. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/__init__.py +7 -0
  3. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/activity_tracker.py +34 -0
  4. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/agent_activity_adapter.py +17 -0
  5. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/agent_process_discovery.py +60 -0
  6. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/agent_repository_tracker.py +206 -0
  7. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/create_process_inspector.py +15 -0
  8. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/git_resolver.py +89 -0
  9. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/linux_process_inspector.py +130 -0
  10. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/mac_process_inspector.py +122 -0
  11. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/paths.py +7 -0
  12. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/process_info.py +65 -0
  13. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/process_inspector.py +16 -0
  14. git_juggler-0.1.3/src/backend/git_juggler/agent_tracking/worktree_evidence.py +167 -0
  15. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/app.py +70 -5
  16. git_juggler-0.1.3/src/backend/git_juggler/browse.py +27 -0
  17. git_juggler-0.1.3/src/backend/git_juggler/ci.py +26 -0
  18. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/config.py +50 -0
  19. git_juggler-0.1.3/src/backend/git_juggler/frontend_dist/assets/index-DrLGhTYA.css +1 -0
  20. git_juggler-0.1.3/src/backend/git_juggler/frontend_dist/assets/index-DxTmj1Il.js +20 -0
  21. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/frontend_dist/index.html +2 -2
  22. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/github_actions.py +10 -8
  23. git_juggler-0.1.3/src/backend/git_juggler/jenkins.py +215 -0
  24. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/repos.py +12 -1
  25. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/schemas.py +97 -4
  26. git_juggler-0.1.3/src/backend/tests/test_agent_tracking.py +428 -0
  27. git_juggler-0.1.3/src/backend/tests/test_jenkins.py +74 -0
  28. git_juggler-0.1.3/src/backend/tests/test_repos.py +40 -0
  29. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/App.css +465 -52
  30. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/App.tsx +8 -3
  31. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/api/client.ts +16 -3
  32. git_juggler-0.1.3/src/frontend/src/api/types.ts +204 -0
  33. git_juggler-0.1.3/src/frontend/src/components/Agents/AgentActivityPanel.tsx +106 -0
  34. 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
  35. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/CommitRow.tsx +5 -5
  36. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/BranchContextMenu.tsx +9 -6
  37. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/CommitContextMenu.tsx +82 -6
  38. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/RepoContextMenu.tsx +9 -6
  39. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/TagContextMenu.tsx +9 -6
  40. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Graph/GraphPanel.tsx +77 -2
  41. git_juggler-0.1.3/src/frontend/src/components/Menu/DirectoryBrowserModal.tsx +44 -0
  42. git_juggler-0.1.3/src/frontend/src/components/Menu/MainMenu.tsx +597 -0
  43. git_juggler-0.1.3/src/frontend/src/components/Sidebar/RepoList.tsx +586 -0
  44. git_juggler-0.1.3/src/frontend/src/components/Tabs/TabsBar.tsx +134 -0
  45. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Terminal/TerminalPanel.tsx +2 -2
  46. git_juggler-0.1.3/src/frontend/src/lib/dismissOnOutsideClick.ts +20 -0
  47. git_juggler-0.1.3/src/frontend/src/lib/flip.ts +21 -0
  48. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/state/store.ts +421 -54
  49. git_juggler-0.1.2/src/backend/git_juggler/frontend_dist/assets/index-Br0FgpWw.css +0 -1
  50. git_juggler-0.1.2/src/backend/git_juggler/frontend_dist/assets/index-Cc_reM34.js +0 -17
  51. git_juggler-0.1.2/src/frontend/src/api/types.ts +0 -109
  52. git_juggler-0.1.2/src/frontend/src/components/Menu/MainMenu.tsx +0 -280
  53. git_juggler-0.1.2/src/frontend/src/components/Sidebar/RepoList.tsx +0 -88
  54. git_juggler-0.1.2/src/frontend/src/components/Tabs/TabsBar.tsx +0 -171
  55. {git_juggler-0.1.2 → git_juggler-0.1.3}/.github/workflows/build-package.yml +0 -0
  56. {git_juggler-0.1.2 → git_juggler-0.1.3}/.gitignore +0 -0
  57. {git_juggler-0.1.2 → git_juggler-0.1.3}/CLAUDE.md +0 -0
  58. {git_juggler-0.1.2 → git_juggler-0.1.3}/LICENSE +0 -0
  59. {git_juggler-0.1.2 → git_juggler-0.1.3}/README.md +0 -0
  60. {git_juggler-0.1.2 → git_juggler-0.1.3}/notes.txt +0 -0
  61. {git_juggler-0.1.2 → git_juggler-0.1.3}/plans/github-actions-settings-and-build-status.md +0 -0
  62. {git_juggler-0.1.2 → git_juggler-0.1.3}/plans/jenkins-settings-and-build-status.md +0 -0
  63. {git_juggler-0.1.2 → git_juggler-0.1.3}/pyproject.toml +0 -0
  64. {git_juggler-0.1.2 → git_juggler-0.1.3}/scripts/build-package.sh +0 -0
  65. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/__init__.py +0 -0
  66. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/commit_detail.py +0 -0
  67. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/dev_app.py +0 -0
  68. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/frontend_dist/favicon.svg +0 -0
  69. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/frontend_dist/icons.svg +0 -0
  70. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/git_data.py +0 -0
  71. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/git_utils.py +0 -0
  72. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/main.py +0 -0
  73. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/git_juggler/terminal.py +0 -0
  74. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/tests/test_git_data.py +0 -0
  75. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/backend/tests/test_github_actions.py +0 -0
  76. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/.gitignore +0 -0
  77. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/README.md +0 -0
  78. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/index.html +0 -0
  79. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/package-lock.json +0 -0
  80. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/package.json +0 -0
  81. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/public/favicon.svg +0 -0
  82. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/public/icons.svg +0 -0
  83. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/BranchBadge.tsx +0 -0
  84. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/StashBadge.tsx +0 -0
  85. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/TagBadge.tsx +0 -0
  86. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Badges/icons.tsx +0 -0
  87. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Branches/DeleteBranchModal.tsx +0 -0
  88. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/CommitDetail.tsx +0 -0
  89. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/CommitList.tsx +0 -0
  90. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Commits/UncommittedRow.tsx +0 -0
  91. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/ContextMenu/CreateTagModal.tsx +0 -0
  92. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Graph/branchColor.ts +0 -0
  93. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Graph/computeColumns.ts +0 -0
  94. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Search/MultiSelect.tsx +0 -0
  95. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Search/SearchBox.tsx +0 -0
  96. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/components/Tags/DeleteTagModal.tsx +0 -0
  97. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/index.css +0 -0
  98. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/index.tsx +0 -0
  99. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/formatDate.ts +0 -0
  100. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/scrollToCommit.ts +0 -0
  101. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/stripAnsi.ts +0 -0
  102. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/src/lib/terminalTheme.ts +0 -0
  103. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/tsconfig.app.json +0 -0
  104. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/tsconfig.json +0 -0
  105. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/tsconfig.node.json +0 -0
  106. {git_juggler-0.1.2 → git_juggler-0.1.3}/src/frontend/vite.config.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-juggler
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Browse multiple local git repos' commit graphs and a shell, from one local web UI.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -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))