claude-code-tools 1.26.5__tar.gz → 1.27.1__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.
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/PKG-INFO +1 -1
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/__init__.py +1 -1
- claude_code_tools-1.27.1/claude_code_tools/amux/activity.py +260 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/cli.py +86 -7
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/detect.py +8 -2
- claude_code_tools-1.27.1/claude_code_tools/amux/filters.py +67 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/model.py +48 -1
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/render.py +14 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/scan.py +2 -1
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_process.py +5 -3
- claude_code_tools-1.27.1/claude_code_tools/codex_server_wait.py +59 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/pyproject.toml +2 -2
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/.gitignore +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/LICENSE +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/README.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/action_rpc.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/__init__.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/backends.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/cli.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/config.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/convert.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/locking.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/paths.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/registry.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/store.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/tmux.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/trust.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/aichat.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/__init__.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/__main__.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/cache.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/claude_continue.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/cli_passthrough.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_app_server_rpc.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_continue.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_callback.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_cli.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_fingerprint.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_generation.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_legacy.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_models.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_reservation.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_resume.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_retry.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_reuse.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_state.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_supervisor.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_worker.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/config.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/csv2gsheet.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/delete_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/dotenv_vault.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/env_safe.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_all.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_claude_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_codex_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_claude_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_codex_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_original_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_trimmed_sessions.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/fix_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/gdoc2docx.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/gdoc2md.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/github_wake.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/github_watch_daemon.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/github_watch_store.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/gsheet2csv.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/helper_sessions.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/issue_reply_delivery.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/legacy_session_wrappers.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/md2gdoc.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/move_account.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/__init__.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/cli.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/hooks.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/migrations.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/models.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/prompt_detect.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/store.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/watcher.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/node_menu_ui.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_claude_noise.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_claude_to_codex.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_codex_flatten.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_codex_to_claude.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_render.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_service.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/resolve_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/resolve_session_names.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/resolve_session_render.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/search_index.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_cli_resolution.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_lineage.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_menu.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_menu_cli.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_resolution.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_selection.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_utils.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/smart_trim.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/smart_trim_core.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/tmux_cli_controller.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/tmux_execution_helpers.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/tmux_remote_controller.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_in_place.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_session.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_session_claude.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_session_codex.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_contract.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_formatting.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_manifest.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_projection.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_rendering.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_snapshots.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_store_backends.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_processes.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_runs.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_store_io.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_validation.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/agent-tunnel-spec.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/claude-code-tmux-tutorials.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/dot-zshrc.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/find-claude-session.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/intercom-spec.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/lmsh.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/local-llm-setup.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/tmux-cli-instructions.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/vault-documentation.md +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/hatch_build.py +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/action_config.js +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/menu.js +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/package-lock.json +0 -0
- {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/package.json +0 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Read bounded, session-scoped evidence of submitted agent input.
|
|
2
|
+
|
|
3
|
+
History timestamps are not pane activity: another pane can resume the same
|
|
4
|
+
conversation. Missing or ambiguous evidence stays unknown. No transcript mtimes,
|
|
5
|
+
launch arguments, or terminal output are used as input timestamps.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import subprocess
|
|
16
|
+
import time
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from .model import Agent
|
|
22
|
+
|
|
23
|
+
_HISTORY_LIMIT = 16 * 1024 * 1024
|
|
24
|
+
_UUID = r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"
|
|
25
|
+
_ROLLOUT = re.compile(rf"rollout-.*-({_UUID})\.jsonl$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _homes(kind: str) -> list[Path]:
|
|
29
|
+
"""Discover immediate profile directories and the explicit current home."""
|
|
30
|
+
root = Path.home()
|
|
31
|
+
paths = [root / f".{kind}"]
|
|
32
|
+
try:
|
|
33
|
+
paths.extend(p for p in root.iterdir() if p.name.startswith(f".{kind}"))
|
|
34
|
+
except OSError:
|
|
35
|
+
pass
|
|
36
|
+
variable = "CLAUDE_CONFIG_DIR" if kind == "claude" else "CODEX_HOME"
|
|
37
|
+
configured = os.environ.get(variable)
|
|
38
|
+
if configured:
|
|
39
|
+
paths.append(Path(configured).expanduser())
|
|
40
|
+
result: list[Path] = []
|
|
41
|
+
for path in paths:
|
|
42
|
+
try:
|
|
43
|
+
resolved = path.resolve()
|
|
44
|
+
if path.is_dir() and resolved not in result:
|
|
45
|
+
result.append(resolved)
|
|
46
|
+
except OSError:
|
|
47
|
+
continue
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _history(home: Path, kind: str) -> tuple[dict[str, float], str]:
|
|
52
|
+
"""Read a bounded history tail, returning timestamps and read status."""
|
|
53
|
+
try:
|
|
54
|
+
with (home / "history.jsonl").open("rb") as stream:
|
|
55
|
+
size = stream.seek(0, 2)
|
|
56
|
+
start = max(0, size - _HISTORY_LIMIT)
|
|
57
|
+
stream.seek(start)
|
|
58
|
+
data = stream.read(_HISTORY_LIMIT)
|
|
59
|
+
if start:
|
|
60
|
+
data = data.partition(b"\n")[2]
|
|
61
|
+
except OSError:
|
|
62
|
+
return {}, "unavailable"
|
|
63
|
+
times: dict[str, float] = {}
|
|
64
|
+
for line in data.splitlines():
|
|
65
|
+
try:
|
|
66
|
+
record = json.loads(line)
|
|
67
|
+
except (ValueError, UnicodeDecodeError):
|
|
68
|
+
continue
|
|
69
|
+
if not isinstance(record, dict):
|
|
70
|
+
continue
|
|
71
|
+
submitted = record.get("display" if kind == "claude" else "text", "")
|
|
72
|
+
if isinstance(submitted, str) and submitted.lstrip().startswith(
|
|
73
|
+
("<hook_prompt", "<task-notification", "<subagent", "<system-reminder")
|
|
74
|
+
):
|
|
75
|
+
continue
|
|
76
|
+
sid = record.get("sessionId" if kind == "claude" else "session_id")
|
|
77
|
+
timestamp = record.get("timestamp" if kind == "claude" else "ts")
|
|
78
|
+
if not isinstance(sid, str) or not sid:
|
|
79
|
+
continue
|
|
80
|
+
if isinstance(timestamp, bool) or not isinstance(timestamp, (int, float)):
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
value = float(timestamp) / (1000 if kind == "claude" else 1)
|
|
84
|
+
except OverflowError:
|
|
85
|
+
continue
|
|
86
|
+
if math.isfinite(value) and 0 < value <= time.time():
|
|
87
|
+
times[sid] = max(times.get(sid, 0), value)
|
|
88
|
+
return times, "tail" if start else "complete"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _run(args: list[str]) -> str | None:
|
|
92
|
+
"""Run a bounded read-only subprocess; failure is distinct from no records."""
|
|
93
|
+
try:
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
args, capture_output=True, text=True, timeout=5,
|
|
96
|
+
env={**os.environ, "TZ": "UTC"},
|
|
97
|
+
)
|
|
98
|
+
except (OSError, subprocess.SubprocessError):
|
|
99
|
+
return None
|
|
100
|
+
return result.stdout if result.returncode == 0 else None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _starts(pids: set[int]) -> dict[int, str]:
|
|
104
|
+
"""Get process start identities to reject stale PID metadata."""
|
|
105
|
+
if not pids:
|
|
106
|
+
return {}
|
|
107
|
+
output = _run(["ps", "-p", ",".join(map(str, sorted(pids))), "-o", "pid=,lstart="])
|
|
108
|
+
result: dict[int, str] = {}
|
|
109
|
+
for line in (output or "").splitlines():
|
|
110
|
+
parts = line.strip().split(None, 1)
|
|
111
|
+
if len(parts) == 2 and parts[0].isdigit():
|
|
112
|
+
result[int(parts[0])] = " ".join(parts[1].split())
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _claude_record(home: Path, pid: int, start: str) -> dict[str, Any] | None:
|
|
117
|
+
"""Accept only runtime metadata matching a currently observed process."""
|
|
118
|
+
try:
|
|
119
|
+
with (home / "sessions" / f"{pid}.json").open("rb") as stream:
|
|
120
|
+
record = json.loads(stream.read(64 * 1024))
|
|
121
|
+
except (OSError, ValueError, UnicodeDecodeError):
|
|
122
|
+
return None
|
|
123
|
+
if not isinstance(record, dict):
|
|
124
|
+
return None
|
|
125
|
+
recorded_start = record.get("procStart")
|
|
126
|
+
sid = record.get("sessionId")
|
|
127
|
+
if (
|
|
128
|
+
record.get("pid") != pid
|
|
129
|
+
or not isinstance(recorded_start, str)
|
|
130
|
+
or " ".join(recorded_start.split()) != start
|
|
131
|
+
or not start
|
|
132
|
+
or not isinstance(sid, str)
|
|
133
|
+
or not sid
|
|
134
|
+
):
|
|
135
|
+
return None
|
|
136
|
+
return record
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _codex_pids(agent: Agent, tree: dict[int, list[tuple[int, str]]]) -> set[int]:
|
|
140
|
+
"""Include native Codex children of the selected launcher, bounded in size."""
|
|
141
|
+
pids = {agent.pid} if agent.pid > 0 else set()
|
|
142
|
+
queue = list(pids)
|
|
143
|
+
while queue and len(pids) < 32:
|
|
144
|
+
for pid, command in tree.get(queue.pop(0), []):
|
|
145
|
+
# Follow only the launcher/native chain, never arbitrary tool workers.
|
|
146
|
+
executable = command.split()[0] if command.split() else ""
|
|
147
|
+
if pid not in pids and Path(executable).name == "codex":
|
|
148
|
+
pids.add(pid)
|
|
149
|
+
queue.append(pid)
|
|
150
|
+
return pids
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _rollouts(pids: set[int]) -> tuple[dict[int, set[Path]], bool]:
|
|
154
|
+
"""Identify open rollout descriptors in one lsof query, without reading them."""
|
|
155
|
+
if not pids:
|
|
156
|
+
return {}, True
|
|
157
|
+
output = _run(["lsof", "-nP", "-a", "-p", ",".join(map(str, sorted(pids))), "-Fn"])
|
|
158
|
+
if output is None:
|
|
159
|
+
return {}, False
|
|
160
|
+
found: dict[int, set[Path]] = {}
|
|
161
|
+
pid = 0
|
|
162
|
+
for line in output.splitlines():
|
|
163
|
+
if line.startswith("p") and line[1:].isdigit():
|
|
164
|
+
pid = int(line[1:])
|
|
165
|
+
elif pid in pids and line.startswith("n"):
|
|
166
|
+
path = Path(line[1:])
|
|
167
|
+
if _ROLLOUT.fullmatch(path.name):
|
|
168
|
+
found.setdefault(pid, set()).add(path)
|
|
169
|
+
return found, True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _goal_status(home: Path, sid: str) -> str:
|
|
173
|
+
"""Read only the matched thread's goal status, never its objective."""
|
|
174
|
+
try:
|
|
175
|
+
uri = (home / "goals_1.sqlite").as_uri() + "?mode=ro"
|
|
176
|
+
connection = sqlite3.connect(uri, uri=True, timeout=0.1)
|
|
177
|
+
try:
|
|
178
|
+
row = connection.execute(
|
|
179
|
+
"SELECT status FROM thread_goals WHERE thread_id = ?", (sid,)
|
|
180
|
+
).fetchone()
|
|
181
|
+
finally:
|
|
182
|
+
connection.close()
|
|
183
|
+
except (sqlite3.Error, ValueError):
|
|
184
|
+
return "unknown"
|
|
185
|
+
return str(row[0]) if row else "none"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def enrich(agents: list[Agent], child_map: dict[int, list[tuple[int, str]]]) -> None:
|
|
189
|
+
"""Add last submitted-input evidence without modifying running sessions.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
agents: Live pane records to enrich in place.
|
|
193
|
+
child_map: Process snapshot containing launcher/native parentage.
|
|
194
|
+
"""
|
|
195
|
+
homes = {kind: _homes(kind) for kind in {a.kind for a in agents}}
|
|
196
|
+
histories: dict[tuple[Path, str], tuple[dict[str, float], str]] = {}
|
|
197
|
+
starts = _starts({a.pid for a in agents if a.kind == "claude" and a.pid > 0})
|
|
198
|
+
pid_sets = {
|
|
199
|
+
a.pane: _codex_pids(a, child_map) for a in agents if a.kind == "codex"
|
|
200
|
+
}
|
|
201
|
+
paths, lsof_ok = _rollouts(set().union(*pid_sets.values()) if pid_sets else set())
|
|
202
|
+
identities: list[tuple[Agent, Path, str]] = []
|
|
203
|
+
for agent in agents:
|
|
204
|
+
agent.session_id = ""
|
|
205
|
+
agent.last_input_at = None
|
|
206
|
+
agent.input_source = "unknown"
|
|
207
|
+
agent.input_scope = "unknown"
|
|
208
|
+
matches: list[tuple[Path, str]] = []
|
|
209
|
+
runtime_records: list[dict[str, Any]] = []
|
|
210
|
+
if agent.kind == "claude":
|
|
211
|
+
for home in homes["claude"]:
|
|
212
|
+
record = _claude_record(home, agent.pid, starts.get(agent.pid, ""))
|
|
213
|
+
if record:
|
|
214
|
+
matches.append((home, record["sessionId"]))
|
|
215
|
+
runtime_records.append(record)
|
|
216
|
+
method = "claude-pid"
|
|
217
|
+
else:
|
|
218
|
+
open_paths = set().union(
|
|
219
|
+
*(paths.get(p, set()) for p in pid_sets[agent.pane])
|
|
220
|
+
)
|
|
221
|
+
for path in open_paths:
|
|
222
|
+
match = _ROLLOUT.fullmatch(path.name)
|
|
223
|
+
for home in homes["codex"]:
|
|
224
|
+
if match and path.is_relative_to(home / "sessions"):
|
|
225
|
+
matches.append((home, match.group(1)))
|
|
226
|
+
method = "codex-open-rollout"
|
|
227
|
+
agent.extra["activity_probe_ok"] = lsof_ok
|
|
228
|
+
matches = list(dict.fromkeys(matches))
|
|
229
|
+
if len(matches) != 1:
|
|
230
|
+
agent.extra["input_evidence"] = "ambiguous" if matches else "unavailable"
|
|
231
|
+
continue
|
|
232
|
+
home, sid = matches[0]
|
|
233
|
+
agent.session_id = sid
|
|
234
|
+
if agent.kind == "claude":
|
|
235
|
+
record = runtime_records[0]
|
|
236
|
+
status = record.get("status", "unknown")
|
|
237
|
+
agent.extra["runtime_status"] = status
|
|
238
|
+
if isinstance(record.get("name"), str) and record["name"]:
|
|
239
|
+
agent.name = record["name"]
|
|
240
|
+
if status in ("shell", "working", "running") and agent.state == "idle":
|
|
241
|
+
agent.state = "bg"
|
|
242
|
+
else:
|
|
243
|
+
goal = _goal_status(home, sid)
|
|
244
|
+
agent.extra["goal_status"] = goal
|
|
245
|
+
if goal == "active" and agent.state == "idle":
|
|
246
|
+
agent.state = "bg"
|
|
247
|
+
agent.input_scope = "session"
|
|
248
|
+
key = (home, agent.kind)
|
|
249
|
+
if key not in histories:
|
|
250
|
+
histories[key] = _history(home, agent.kind)
|
|
251
|
+
timestamps, status = histories[key]
|
|
252
|
+
agent.last_input_at = timestamps.get(sid)
|
|
253
|
+
agent.input_source = f"{method}/history-{status}"
|
|
254
|
+
agent.extra["activity_home"] = str(home)
|
|
255
|
+
agent.extra["input_evidence"] = "found" if sid in timestamps else "unavailable"
|
|
256
|
+
identities.append((agent, home, sid))
|
|
257
|
+
counts = Counter((home, sid) for _, home, sid in identities)
|
|
258
|
+
for agent, home, sid in identities:
|
|
259
|
+
if counts[home, sid] > 1:
|
|
260
|
+
agent.input_scope = "shared-session"
|
|
@@ -20,11 +20,12 @@ import subprocess
|
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
22
|
from . import cache, render, scan
|
|
23
|
+
from .filters import directory_argument, select_agents
|
|
23
24
|
from .model import Agent
|
|
24
25
|
|
|
25
26
|
_FZF_HEADER = (
|
|
26
27
|
"enter=jump ctrl-r=refresh esc=quit "
|
|
27
|
-
"input=asking you busy=working bg=
|
|
28
|
+
"input=asking you busy=working bg=background age=last submitted input"
|
|
28
29
|
)
|
|
29
30
|
|
|
30
31
|
|
|
@@ -44,11 +45,30 @@ def _agents_for_display(max_age: float) -> tuple[list[Agent], bool]:
|
|
|
44
45
|
return _fresh(), False
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
def _display(agents: list[Agent], args: argparse.Namespace) -> list[Agent]:
|
|
49
|
+
"""Refresh ages even for cached rows, then apply the requested view."""
|
|
50
|
+
agents = select_agents(
|
|
51
|
+
agents, getattr(args, "repo", None), getattr(args, "directory", None),
|
|
52
|
+
)
|
|
53
|
+
for agent in agents:
|
|
54
|
+
agent.classify_inactivity(args.dormant_hours)
|
|
55
|
+
if args.dormant:
|
|
56
|
+
agents = [a for a in agents if a.inactivity == "dormant"]
|
|
57
|
+
if args.sort == "oldest":
|
|
58
|
+
agents = sorted(agents, key=lambda a: (
|
|
59
|
+
a.last_input_at is None,
|
|
60
|
+
a.last_input_at if a.last_input_at is not None else 0,
|
|
61
|
+
a.pane,
|
|
62
|
+
))
|
|
63
|
+
return agents
|
|
64
|
+
|
|
65
|
+
|
|
47
66
|
def cmd_list(args: argparse.Namespace) -> int:
|
|
48
67
|
"""Print every live agent as a table or JSON."""
|
|
49
68
|
agents = cache.read(max_age=args.max_age)[0] if args.cached else _fresh()
|
|
50
69
|
if not agents and args.cached:
|
|
51
70
|
agents = _fresh()
|
|
71
|
+
agents = _display(agents, args)
|
|
52
72
|
if args.json:
|
|
53
73
|
print(render.as_json(agents))
|
|
54
74
|
else:
|
|
@@ -67,7 +87,7 @@ def cmd_scan(args: argparse.Namespace) -> int:
|
|
|
67
87
|
def cmd_rows(args: argparse.Namespace) -> int:
|
|
68
88
|
"""Emit picker rows (internal: used by fzf's reload binding)."""
|
|
69
89
|
agents = _fresh() if args.refresh else _agents_for_display(args.max_age)[0]
|
|
70
|
-
print(render.picker_lines(agents, colour=True))
|
|
90
|
+
print(render.picker_lines(_display(agents, args), colour=True))
|
|
71
91
|
return 0
|
|
72
92
|
|
|
73
93
|
|
|
@@ -81,18 +101,37 @@ def cmd_pick(args: argparse.Namespace) -> int:
|
|
|
81
101
|
return 1
|
|
82
102
|
|
|
83
103
|
agents, from_cache = _agents_for_display(args.max_age)
|
|
104
|
+
agents = _display(agents, args)
|
|
105
|
+
if not agents and from_cache:
|
|
106
|
+
agents = _display(_fresh(), args)
|
|
107
|
+
from_cache = False
|
|
84
108
|
if not agents:
|
|
85
|
-
print("no agents
|
|
109
|
+
print("no matching agents")
|
|
86
110
|
return 0
|
|
87
111
|
|
|
88
112
|
# fzf runs bind commands through a shell, so the interpreter path must be
|
|
89
113
|
# quoted: a venv at "/tmp/my venv/bin/python" would otherwise run "/tmp/my".
|
|
90
114
|
self_cmd = f"{shlex.quote(sys.executable)} -m claude_code_tools.amux"
|
|
115
|
+
view_flags = (
|
|
116
|
+
f" --dormant-hours {args.dormant_hours} --sort {args.sort}"
|
|
117
|
+
+ (" --dormant" if args.dormant else "")
|
|
118
|
+
)
|
|
119
|
+
# Keep user values out of fzf's action/placeholder parser as well as the shell.
|
|
120
|
+
reload_env = dict(os.environ)
|
|
121
|
+
if args.repo is not None:
|
|
122
|
+
reload_env["AMUX_RELOAD_REPO"] = args.repo
|
|
123
|
+
view_flags += ' --repo="$AMUX_RELOAD_REPO"'
|
|
124
|
+
if args.directory is not None:
|
|
125
|
+
reload_env["AMUX_RELOAD_DIR"] = args.directory
|
|
126
|
+
view_flags += ' --dir="$AMUX_RELOAD_DIR"'
|
|
91
127
|
binds = [
|
|
92
|
-
f"ctrl-r:reload({self_cmd} rows --refresh)",
|
|
128
|
+
f"ctrl-r:reload({self_cmd} rows --refresh{view_flags})",
|
|
93
129
|
# Opening on cached rows is what makes this instant; refresh the
|
|
94
130
|
# moment the list is up so stale states self-correct without a keypress.
|
|
95
|
-
|
|
131
|
+
(
|
|
132
|
+
f"load:reload-sync({self_cmd} rows --refresh{view_flags})"
|
|
133
|
+
if from_cache else ""
|
|
134
|
+
),
|
|
96
135
|
]
|
|
97
136
|
cmd = [
|
|
98
137
|
"fzf",
|
|
@@ -126,6 +165,7 @@ def cmd_pick(args: argparse.Namespace) -> int:
|
|
|
126
165
|
input=render.picker_lines(agents),
|
|
127
166
|
stdout=subprocess.PIPE,
|
|
128
167
|
text=True,
|
|
168
|
+
env=reload_env,
|
|
129
169
|
)
|
|
130
170
|
if proc.returncode != 0 or not proc.stdout.strip():
|
|
131
171
|
return 0
|
|
@@ -182,6 +222,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
182
222
|
rows.add_argument("--refresh", action="store_true")
|
|
183
223
|
rows.set_defaults(func=cmd_rows)
|
|
184
224
|
|
|
225
|
+
for command in (pick, lst, rows, scan_cmd):
|
|
226
|
+
command.add_argument(
|
|
227
|
+
"--max-age", type=_finite_seconds, default=argparse.SUPPRESS,
|
|
228
|
+
help="seconds a cached scan stays usable (default: 30)",
|
|
229
|
+
)
|
|
230
|
+
for command in (pick, lst, rows):
|
|
231
|
+
command.add_argument(
|
|
232
|
+
"--repo", "--project", dest="repo", metavar="NAME",
|
|
233
|
+
help="exact repository name, including linked Git worktrees",
|
|
234
|
+
)
|
|
235
|
+
command.add_argument(
|
|
236
|
+
"--dir", dest="directory", type=directory_argument, metavar="PATH",
|
|
237
|
+
help="only agents in this directory or its subdirectories",
|
|
238
|
+
)
|
|
239
|
+
command.add_argument(
|
|
240
|
+
"--dormant", action="store_true",
|
|
241
|
+
help="show waiting agents with old known submitted input only",
|
|
242
|
+
)
|
|
243
|
+
command.add_argument(
|
|
244
|
+
"--dormant-hours", type=_finite_seconds, default=72.0,
|
|
245
|
+
help="input age in hours above which waiting agents are dormant (72)",
|
|
246
|
+
)
|
|
247
|
+
command.add_argument(
|
|
248
|
+
"--sort", choices=("state", "oldest"), default="state",
|
|
249
|
+
help="sort by current state or oldest known submitted input",
|
|
250
|
+
)
|
|
185
251
|
return parser
|
|
186
252
|
|
|
187
253
|
|
|
@@ -193,8 +259,21 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
193
259
|
# only ["pick"] -- reparsing dropped global options, so `amux --max-age 0`
|
|
194
260
|
# silently used the 30s default.
|
|
195
261
|
known = {"pick", "list", "scan", "rows"}
|
|
196
|
-
|
|
197
|
-
|
|
262
|
+
value_options = {
|
|
263
|
+
"--repo", "--project", "--dir", "--max-age", "--dormant-hours", "--sort",
|
|
264
|
+
}
|
|
265
|
+
tokens = iter(raw)
|
|
266
|
+
explicit_command = False
|
|
267
|
+
for token in tokens:
|
|
268
|
+
if token in value_options:
|
|
269
|
+
next(tokens, None)
|
|
270
|
+
elif token in known | {"-h", "--help"}:
|
|
271
|
+
explicit_command = True
|
|
272
|
+
break
|
|
273
|
+
elif not token.startswith("-"):
|
|
274
|
+
break
|
|
275
|
+
if not explicit_command:
|
|
276
|
+
raw.insert(0, "pick")
|
|
198
277
|
args = parser.parse_args(raw)
|
|
199
278
|
if not scan.tmux_available():
|
|
200
279
|
print("amux: no tmux server running", file=sys.stderr)
|
|
@@ -93,7 +93,9 @@ _BUSY = re.compile(
|
|
|
93
93
|
#: those can be used). Without this a pane with a working subagent but a free
|
|
94
94
|
#: main prompt reported as idle.
|
|
95
95
|
_BG = re.compile(
|
|
96
|
-
r"\b\d
|
|
96
|
+
r"\b[1-9]\d*\s+monitors?\b|"
|
|
97
|
+
r"\b[1-9]\d*\s+shells? still running\b|"
|
|
98
|
+
r"^\s*⏵⏵.*·\s*[1-9]\d*\s+shells?\b|^\s*◯\s+\S",
|
|
97
99
|
re.MULTILINE,
|
|
98
100
|
)
|
|
99
101
|
|
|
@@ -136,7 +138,11 @@ def detect_state(screen: str, kind: Kind) -> State:
|
|
|
136
138
|
return "busy"
|
|
137
139
|
if kind == "codex" and _codex_awaiting_answer(tail):
|
|
138
140
|
return "input"
|
|
139
|
-
|
|
141
|
+
background_lines = "\n".join(
|
|
142
|
+
line.rstrip() for line in screen.splitlines()
|
|
143
|
+
if not re.search(r"^\s*◯.*\bidle\s*$", line)
|
|
144
|
+
)
|
|
145
|
+
if _BG.search(background_lines):
|
|
140
146
|
return "bg"
|
|
141
147
|
return "idle"
|
|
142
148
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Repository and directory selection for Amux display views."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .model import Agent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def directory_argument(value: str) -> str:
|
|
12
|
+
"""Resolve an existing directory supplied on the command line."""
|
|
13
|
+
try:
|
|
14
|
+
path = Path(value).expanduser().resolve()
|
|
15
|
+
if path.is_dir():
|
|
16
|
+
return str(path)
|
|
17
|
+
except (OSError, RuntimeError) as exc:
|
|
18
|
+
raise argparse.ArgumentTypeError(str(exc)) from exc
|
|
19
|
+
raise argparse.ArgumentTypeError(f"not a directory: {value!r}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def repository_name(cwd: str) -> str:
|
|
23
|
+
"""Find the shared repository's directory name, including linked worktrees."""
|
|
24
|
+
if not cwd:
|
|
25
|
+
return ""
|
|
26
|
+
try:
|
|
27
|
+
path = Path(cwd).resolve()
|
|
28
|
+
for root in (path, *path.parents):
|
|
29
|
+
marker = root / ".git"
|
|
30
|
+
if marker.is_dir():
|
|
31
|
+
return root.name
|
|
32
|
+
if marker.is_file():
|
|
33
|
+
pointer = marker.read_text().strip()
|
|
34
|
+
if not pointer.startswith("gitdir: "):
|
|
35
|
+
return ""
|
|
36
|
+
git_dir = (root / pointer.removeprefix("gitdir: ")).resolve()
|
|
37
|
+
common_file = git_dir / "commondir"
|
|
38
|
+
if not common_file.is_file():
|
|
39
|
+
return root.name
|
|
40
|
+
common = (git_dir / common_file.read_text().strip()).resolve()
|
|
41
|
+
return common.parent.name if common.name == ".git" else common.name
|
|
42
|
+
except (OSError, RuntimeError, UnicodeError):
|
|
43
|
+
return ""
|
|
44
|
+
return ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def select_agents(
|
|
48
|
+
agents: list[Agent], repo: str | None, directory: str | None,
|
|
49
|
+
) -> list[Agent]:
|
|
50
|
+
"""Apply exact repository-name and directory-subtree filters together."""
|
|
51
|
+
selected = []
|
|
52
|
+
names: dict[str, str] = {}
|
|
53
|
+
for agent in agents:
|
|
54
|
+
if repo is not None and agent.repo != repo:
|
|
55
|
+
if agent.cwd not in names:
|
|
56
|
+
names[agent.cwd] = repository_name(agent.cwd)
|
|
57
|
+
if names[agent.cwd] != repo:
|
|
58
|
+
continue
|
|
59
|
+
if directory:
|
|
60
|
+
if not agent.cwd:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
Path(agent.cwd).resolve().relative_to(directory)
|
|
64
|
+
except (ValueError, OSError, RuntimeError):
|
|
65
|
+
continue
|
|
66
|
+
selected.append(agent)
|
|
67
|
+
return selected
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
5
7
|
from dataclasses import asdict, dataclass, field
|
|
6
8
|
from typing import Any, Literal
|
|
7
9
|
|
|
@@ -37,6 +39,12 @@ class Agent:
|
|
|
37
39
|
model: Model string parsed from the harness footer, e.g. ``opus-5``.
|
|
38
40
|
info: One-line context lifted from the pane's footer.
|
|
39
41
|
pid: PID of the agent process itself (not the pane's shell).
|
|
42
|
+
session_id: Current conversation ID, only when reliably matched.
|
|
43
|
+
last_input_at: Last recorded submitted user input, Unix seconds.
|
|
44
|
+
input_source: Identity method and history read status, or unknown.
|
|
45
|
+
input_scope: Session-level evidence; shared-session for duplicate panes.
|
|
46
|
+
inactivity: Working, recent, dormant, or unknown input-age classification.
|
|
47
|
+
input_age_seconds: Age calculated at display time, including cached rows.
|
|
40
48
|
"""
|
|
41
49
|
|
|
42
50
|
pane: str
|
|
@@ -50,8 +58,37 @@ class Agent:
|
|
|
50
58
|
model: str = ""
|
|
51
59
|
info: str = ""
|
|
52
60
|
pid: int = 0
|
|
61
|
+
session_id: str = ""
|
|
62
|
+
last_input_at: float | None = None
|
|
63
|
+
input_source: str = "unknown"
|
|
64
|
+
input_scope: str = "unknown"
|
|
65
|
+
inactivity: str = "unknown"
|
|
66
|
+
input_age_seconds: float | None = None
|
|
53
67
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
54
68
|
|
|
69
|
+
def classify_inactivity(
|
|
70
|
+
self, threshold_hours: float = 72, now: float | None = None
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Classify old submitted input independently of current activity."""
|
|
73
|
+
now = time.time() if now is None else now
|
|
74
|
+
self.input_age_seconds = (
|
|
75
|
+
max(0, now - self.last_input_at)
|
|
76
|
+
if self.last_input_at is not None else None
|
|
77
|
+
)
|
|
78
|
+
working = (
|
|
79
|
+
self.state in ("busy", "bg")
|
|
80
|
+
or self.extra.get("runtime_status") in ("shell", "working", "running")
|
|
81
|
+
or self.extra.get("goal_status") == "active"
|
|
82
|
+
)
|
|
83
|
+
if working:
|
|
84
|
+
self.inactivity = "working"
|
|
85
|
+
elif self.input_age_seconds is None:
|
|
86
|
+
self.inactivity = "unknown"
|
|
87
|
+
elif self.input_age_seconds > threshold_hours * 3600:
|
|
88
|
+
self.inactivity = "dormant"
|
|
89
|
+
else:
|
|
90
|
+
self.inactivity = "recent"
|
|
91
|
+
|
|
55
92
|
@property
|
|
56
93
|
def rank(self) -> int:
|
|
57
94
|
"""Urgency rank used for sorting (lower sorts first)."""
|
|
@@ -74,13 +111,23 @@ class Agent:
|
|
|
74
111
|
value = known.get(field_name)
|
|
75
112
|
if value is not None and not isinstance(value, str):
|
|
76
113
|
return None
|
|
77
|
-
for field_name in ("name", "cwd", "repo", "branch", "model", "info"
|
|
114
|
+
for field_name in ("name", "cwd", "repo", "branch", "model", "info",
|
|
115
|
+
"session_id", "input_source", "input_scope", "inactivity"):
|
|
78
116
|
if not isinstance(known.get(field_name, ""), str):
|
|
79
117
|
return None
|
|
80
118
|
if known.get("pane") is None or known.get("session") is None:
|
|
81
119
|
return None
|
|
82
120
|
if not isinstance(known.get("pid", 0), int):
|
|
83
121
|
return None
|
|
122
|
+
for key in ("last_input_at", "input_age_seconds"):
|
|
123
|
+
value = known.get(key)
|
|
124
|
+
if value is not None and (
|
|
125
|
+
isinstance(value, bool) or not isinstance(value, (int, float))
|
|
126
|
+
or not math.isfinite(value) or value < 0
|
|
127
|
+
):
|
|
128
|
+
return None
|
|
129
|
+
if not isinstance(known.get("extra", {}), dict):
|
|
130
|
+
return None
|
|
84
131
|
# Literal[...] is a type-checker hint only; a hand-edited cache can
|
|
85
132
|
# still carry kind="vim" or state="waiting" and render as an agent.
|
|
86
133
|
if known.get("kind") not in ("claude", "codex"):
|
|
@@ -29,6 +29,18 @@ def _colour(state: str, text: str, colour: bool) -> str:
|
|
|
29
29
|
return f"\033[{STATE_COLOR.get(state, '0')}m{text}\033[0m"
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def input_age(agent: Agent) -> str:
|
|
33
|
+
"""Compact age of the last submitted input, never terminal output age."""
|
|
34
|
+
age = agent.input_age_seconds
|
|
35
|
+
if age is None:
|
|
36
|
+
return "unknown"
|
|
37
|
+
if age >= 86400:
|
|
38
|
+
return f"{age / 86400:.1f}d"
|
|
39
|
+
if age >= 3600:
|
|
40
|
+
return f"{age / 3600:.1f}h"
|
|
41
|
+
return f"{int(age // 60)}m"
|
|
42
|
+
|
|
43
|
+
|
|
32
44
|
def picker_row(agent: Agent, colour: bool = True) -> str:
|
|
33
45
|
"""The visible part of an fzf row (no key field); see :func:`picker_lines`."""
|
|
34
46
|
state = _colour(agent.state, f"{agent.state:<{_W_STATE}}", colour)
|
|
@@ -36,6 +48,7 @@ def picker_row(agent: Agent, colour: bool = True) -> str:
|
|
|
36
48
|
where = _clip(f"{agent.repo}{branch}", _W_REPO)
|
|
37
49
|
return (
|
|
38
50
|
f"{_clip(agent.pane, _W_PANE):<{_W_PANE}} {state} {agent.kind:<{_W_KIND}} "
|
|
51
|
+
f"{input_age(agent):>9} {agent.inactivity:<8} "
|
|
39
52
|
f"{_clip(agent.name or '-', _W_NAME):<{_W_NAME}} "
|
|
40
53
|
f"{where:<{_W_REPO}} {agent.info}"
|
|
41
54
|
)
|
|
@@ -74,6 +87,7 @@ def table(agents: list[Agent], colour: bool = True) -> str:
|
|
|
74
87
|
|
|
75
88
|
header = (
|
|
76
89
|
f"{'PANE':<{_W_PANE}} {'STATE':<{_W_STATE}} {'KIND':<{_W_KIND}} "
|
|
90
|
+
f"{'INPUT AGE':>9} {'ACTIVITY':<8} "
|
|
77
91
|
f"{'NAME':<{_W_NAME}} {'REPO@BRANCH':<{_W_REPO}} CONTEXT"
|
|
78
92
|
)
|
|
79
93
|
rule = "─" * min(len(header) + 20, 120)
|
|
@@ -19,7 +19,7 @@ import subprocess
|
|
|
19
19
|
from concurrent.futures import ThreadPoolExecutor
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
|
|
22
|
-
from . import detect
|
|
22
|
+
from . import activity, detect
|
|
23
23
|
from .model import Agent
|
|
24
24
|
|
|
25
25
|
#: Field separator for tmux -F output; chosen to not occur in paths or titles.
|
|
@@ -238,6 +238,7 @@ def scan(workers: int = 16) -> list[Agent]:
|
|
|
238
238
|
)
|
|
239
239
|
)
|
|
240
240
|
|
|
241
|
+
activity.enrich(agents, child_map)
|
|
241
242
|
agents.sort(key=lambda a: (a.rank, a.pane))
|
|
242
243
|
return agents
|
|
243
244
|
|