diff --git a/src/aipass/drone/README.md b/src/aipass/drone/README.md index c0293753..a0b634d7 100644 --- a/src/aipass/drone/README.md +++ b/src/aipass/drone/README.md @@ -51,6 +51,7 @@ drone @git commit "msg" --all # Stage ALL repo changes and commit drone @git commit "msg" f1 f2 # Stage only f1 f2, then commit drone @git checkout dev # Switch to dev branch drone @git checkout main # Switch to main branch +drone @git pr "desc" # Push current branch and create PR to main drone @git dev-pr "desc" # Push dev and create PR to main drone @git merge # Merge a PR and sync local main drone @git delete-branch # Delete a remote branch (not main/dev) @@ -59,7 +60,7 @@ drone @git sync # Pull latest (branch-aware: main or dev) drone @git sync --autostash # Sync with autostash for dirty trees drone @git smart-sync # Fetch + detect divergence + rebase drone @git unlock --force # Force-release the PR lock -drone @git system-pr "desc" # DEPRECATED — use dev-pr instead +drone @git system-pr "desc" # DEPRECATED — returns error message drone @git fix # Auto-fix stuck rebase / detached HEAD drone @git fix --dry-run # Detect issues without fixing @@ -216,7 +217,7 @@ Auth centralized via `verify_git_access()` in `apps/plugins/devpulse_ops/auth.py | Tier | Who | Commands | |------|-----|----------| | **Global** | All branches | `status`, `diff`, `log`, `lock`, `branches`, `issue`, `run`, `workflow` | -| **Owner** | `devpulse` only | `commit`, `checkout`, `dev-pr`, `delete-branch`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` | +| **Owner** | `devpulse` only | `pr`, `commit`, `checkout`, `dev-pr`, `delete-branch`, `sync`, `unlock`, `system-pr`, `merge`, `smart-sync`, `fix` | - Auth is checked once at the top of `git_module.handle_command()` before any handler is called - Unauthorized commands return a clear "Access denied" message with the caller's tier @@ -227,6 +228,8 @@ All work happens on `dev`. Only devpulse has write access. Agents build and repo **Flow:** work on dev → stack changes → `drone @git dev-pr "desc"` → merge PR → `drone @git sync` (realigns dev from main) +**`pr` vs `dev-pr`:** `pr` works from any branch — on main it auto-creates a temp branch from the description slug (`main:`), on other branches it pushes directly. Does NOT use `-u` so main's upstream tracking stays on `origin/main`. `dev-pr` is specific to the dev→main workflow. + Enforcement layers: - `git_gate.py` PreToolUse hook blocks ALL raw git/gh commands - Drone tier system restricts write commands to devpulse only diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index 4663596e..d4b2cae5 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -545,7 +545,10 @@ def get_help(command: str | None = None) -> str: "git workflow [args] — Passthrough to gh workflow CLI [global]\n Examples: list, view , run \n" ) if command == "pr": - return "git pr — Push current branch and create PR to main [owner]\n" + return ( + "git pr — Push current branch and create PR to main [owner]\n" + " On main: creates temp branch from slug. Otherwise: pushes branch directly. No -u flag.\n" + ) if command == "status": return "git status [--all] — Show git status filtered to your branch (--all for repo-wide) [global]\n" if command == "diff": @@ -650,7 +653,7 @@ def get_introspective() -> str: " - commit_handler.py (commit_changes — selective files, --all, or pre-staged)\n" " - checkout_handler.py (checkout_branch — main/dev only)\n" " - sync_handler.py (sync_main — safe main synchronization)\n" - " - dev_pr_handler.py (create_dev_pr — push dev, PR to main)\n" + " - dev_pr_handler.py (create_branch_pr, create_dev_pr — PR to main)\n" " - branches_handler.py (list_remote_branches)\n" " - delete_branch_handler.py (delete_remote_branch — protected: main/dev)\n" " - pr_handler.py (create_pr — DEPRECATED, kept for reference)\n" @@ -665,7 +668,7 @@ def get_introspective() -> str: " gh passthrough:\n" " - issue, run, workflow → subprocess gh [args]\n" "\n" - "Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (commit, checkout, dev-pr, delete-branch, sync, unlock, system-pr, merge, smart-sync, fix)\n" + "Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, sync, unlock, system-pr, merge, smart-sync, fix)\n" ) diff --git a/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py b/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py index 807d0ed7..6e91faeb 100644 --- a/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py +++ b/src/aipass/prax/apps/handlers/monitoring/filesystem_handler.py @@ -535,11 +535,15 @@ class MonitoringFileHandler(FileSystemEventHandler): # CLAUDE CODE AGENT ACTIVITY PARSING (JSONL sessions) # ========================================================================= + _MAX_AGENT_EVENTS_PER_BATCH = 3 + def _parse_agent_activity(self, file_path, branch): """Parse Claude Code session JSONL to show agent actions. Returns True if an event was emitted (or deduped), False on failure. - Iterates forward through new lines, emitting all distinct actions. + Iterates forward through all new lines (for model extraction), but only + emits the last few distinct actions to prevent doom-scrolling when large + batches accumulate between polls or after compaction resets. """ try: path_key = str(file_path) @@ -560,6 +564,9 @@ class MonitoringFileHandler(FileSystemEventHandler): if not lines: return True + pending_actions = [] + last_seen = self._last_agent_action.get(path_key) + for line in lines: try: entry = _json.loads(line) @@ -575,10 +582,12 @@ class MonitoringFileHandler(FileSystemEventHandler): if not action_text: continue - if self._last_agent_action.get(path_key) == action_text: + if action_text == last_seen: continue - self._last_agent_action[path_key] = action_text + last_seen = action_text + pending_actions.append(action_text) + for action_text in pending_actions[-self._MAX_AGENT_EVENTS_PER_BATCH :]: tagged_branch = self._tag_branch_with_model(path_key, branch) evt = MonitoringEvent( priority=1, @@ -591,6 +600,9 @@ class MonitoringFileHandler(FileSystemEventHandler): if self._event_queue: self._event_queue.enqueue(evt) + if pending_actions: + self._last_agent_action[path_key] = pending_actions[-1] + return True except Exception as e: diff --git a/src/aipass/prax/tests/test_filesystem_handler.py b/src/aipass/prax/tests/test_filesystem_handler.py index 0268aef4..32060233 100644 --- a/src/aipass/prax/tests/test_filesystem_handler.py +++ b/src/aipass/prax/tests/test_filesystem_handler.py @@ -1763,6 +1763,56 @@ class TestParseAgentActivity: result = handler._parse_agent_activity(mock_path, "PRAX") assert result is True + def test_batch_cap_limits_emitted_events(self): + """Should only emit last N events when batch exceeds cap.""" + mod, _, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entries = [] + tools = ["Read", "Edit", "Write", "Grep", "Glob", "Bash"] + for i, tool in enumerate(tools): + entries.append( + { + "type": "assistant", + "message": {"content": [{"type": "tool_use", "name": tool, "input": {"file_path": f"f{i}.py"}}]}, + } + ) + data = "\n".join(json.dumps(e) for e in entries) + "\n" + mock_path.stat.return_value.st_size = len(data) + with patch("builtins.open", _mopen(read_data=data)): + result = handler._parse_agent_activity(mock_path, "PRAX") + assert result is True + cap = mod.MonitoringFileHandler._MAX_AGENT_EVENTS_PER_BATCH + assert queue.enqueue.call_count == cap + + def test_batch_cap_emits_tail_not_head(self): + """Should emit the last actions in a large batch, not the first.""" + mod, mock_eq, _, _, queue = _import_filesystem_handler() + handler = _make_handler(mod, queue=queue) + mock_path = MagicMock() + mock_path.__str__ = MagicMock(return_value="/fake/claude.jsonl") + mock_path.name = "claude.jsonl" + entries = [] + for i in range(6): + entries.append( + { + "type": "assistant", + "message": {"content": [{"type": "tool_use", "name": f"Tool{i}", "input": {}}]}, + } + ) + data = "\n".join(json.dumps(e) for e in entries) + "\n" + mock_path.stat.return_value.st_size = len(data) + with patch("builtins.open", _mopen(read_data=data)): + handler._parse_agent_activity(mock_path, "PRAX") + cap = mod.MonitoringFileHandler._MAX_AGENT_EVENTS_PER_BATCH + assert queue.enqueue.call_count == cap + last_call_kwargs = mock_eq.MonitoringEvent.call_args_list[-1][1] + assert "Tool5" in last_call_kwargs["message"] + first_call_kwargs = mock_eq.MonitoringEvent.call_args_list[-cap][1] + assert "Tool3" in first_call_kwargs["message"] + # ============================================= # CHECK COMMAND INDICATOR TESTS