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.
Files changed (139) hide show
  1. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/PKG-INFO +1 -1
  2. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/__init__.py +1 -1
  3. claude_code_tools-1.27.1/claude_code_tools/amux/activity.py +260 -0
  4. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/cli.py +86 -7
  5. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/detect.py +8 -2
  6. claude_code_tools-1.27.1/claude_code_tools/amux/filters.py +67 -0
  7. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/model.py +48 -1
  8. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/render.py +14 -0
  9. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/scan.py +2 -1
  10. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_process.py +5 -3
  11. claude_code_tools-1.27.1/claude_code_tools/codex_server_wait.py +59 -0
  12. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/pyproject.toml +2 -2
  13. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/.gitignore +0 -0
  14. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/LICENSE +0 -0
  15. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/README.md +0 -0
  16. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/action_rpc.py +0 -0
  17. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  18. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/backends.py +0 -0
  19. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/cli.py +0 -0
  20. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/config.py +0 -0
  21. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/convert.py +0 -0
  22. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  23. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/locking.py +0 -0
  24. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/paths.py +0 -0
  25. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/registry.py +0 -0
  26. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/session.py +0 -0
  27. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/store.py +0 -0
  28. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  29. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/agent_tunnel/trust.py +0 -0
  30. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/aichat.py +0 -0
  31. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/__init__.py +0 -0
  32. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/__main__.py +0 -0
  33. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/amux/cache.py +0 -0
  34. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/claude_continue.py +0 -0
  35. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/cli_passthrough.py +0 -0
  36. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_app_server_rpc.py +0 -0
  37. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_continue.py +0 -0
  38. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server.py +0 -0
  39. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_callback.py +0 -0
  40. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_cli.py +0 -0
  41. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_fingerprint.py +0 -0
  42. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_generation.py +0 -0
  43. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_legacy.py +0 -0
  44. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_models.py +0 -0
  45. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_reservation.py +0 -0
  46. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_resume.py +0 -0
  47. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_retry.py +0 -0
  48. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_reuse.py +0 -0
  49. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_state.py +0 -0
  50. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_supervisor.py +0 -0
  51. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/codex_server_worker.py +0 -0
  52. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/config.py +0 -0
  53. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/csv2gsheet.py +0 -0
  54. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/delete_session.py +0 -0
  55. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/dotenv_vault.py +0 -0
  56. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/env_safe.py +0 -0
  57. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_all.py +0 -0
  58. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_claude_session.py +0 -0
  59. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_codex_session.py +0 -0
  60. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/export_session.py +0 -0
  61. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_claude_session.py +0 -0
  62. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_codex_session.py +0 -0
  63. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_original_session.py +0 -0
  64. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_session.py +0 -0
  65. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/find_trimmed_sessions.py +0 -0
  66. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/fix_session.py +0 -0
  67. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/gdoc2docx.py +0 -0
  68. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/gdoc2md.py +0 -0
  69. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/github_wake.py +0 -0
  70. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/github_watch_daemon.py +0 -0
  71. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/github_watch_store.py +0 -0
  72. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/gsheet2csv.py +0 -0
  73. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/helper_sessions.py +0 -0
  74. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/issue_reply_delivery.py +0 -0
  75. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/legacy_session_wrappers.py +0 -0
  76. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/md2gdoc.py +0 -0
  77. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/move_account.py +0 -0
  78. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/__init__.py +0 -0
  79. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/cli.py +0 -0
  80. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/hooks.py +0 -0
  81. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/migrations.py +0 -0
  82. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/models.py +0 -0
  83. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/prompt_detect.py +0 -0
  84. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/store.py +0 -0
  85. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/msg/watcher.py +0 -0
  86. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/node_menu_ui.py +0 -0
  87. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_claude_noise.py +0 -0
  88. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_claude_to_codex.py +0 -0
  89. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_codex_flatten.py +0 -0
  90. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_codex_to_claude.py +0 -0
  91. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_render.py +0 -0
  92. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/port_service.py +0 -0
  93. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/resolve_session.py +0 -0
  94. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/resolve_session_names.py +0 -0
  95. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/resolve_session_render.py +0 -0
  96. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/search_index.py +0 -0
  97. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_cli_resolution.py +0 -0
  98. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_lineage.py +0 -0
  99. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_menu.py +0 -0
  100. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_menu_cli.py +0 -0
  101. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_resolution.py +0 -0
  102. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_selection.py +0 -0
  103. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/session_utils.py +0 -0
  104. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/smart_trim.py +0 -0
  105. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/smart_trim_core.py +0 -0
  106. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/tmux_cli_controller.py +0 -0
  107. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/tmux_execution_helpers.py +0 -0
  108. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/tmux_remote_controller.py +0 -0
  109. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_in_place.py +0 -0
  110. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_session.py +0 -0
  111. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_session_claude.py +0 -0
  112. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/trim_session_codex.py +0 -0
  113. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli.py +0 -0
  114. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_contract.py +0 -0
  115. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_formatting.py +0 -0
  116. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  117. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_manifest.py +0 -0
  118. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_projection.py +0 -0
  119. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_rendering.py +0 -0
  120. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  121. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  122. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_processes.py +0 -0
  123. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_runs.py +0 -0
  124. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_store_io.py +0 -0
  125. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/claude_code_tools/workflow_validation.py +0 -0
  126. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/agent-tunnel-spec.md +0 -0
  127. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/claude-code-tmux-tutorials.md +0 -0
  128. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/dot-zshrc.md +0 -0
  129. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/find-claude-session.md +0 -0
  130. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/intercom-spec.md +0 -0
  131. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/lmsh.md +0 -0
  132. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/local-llm-setup.md +0 -0
  133. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/tmux-cli-instructions.md +0 -0
  134. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/docs/vault-documentation.md +0 -0
  135. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/hatch_build.py +0 -0
  136. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/action_config.js +0 -0
  137. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/menu.js +0 -0
  138. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/package-lock.json +0 -0
  139. {claude_code_tools-1.26.5 → claude_code_tools-1.27.1}/node_ui/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-tools
3
- Version: 1.26.5
3
+ Version: 1.27.1
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.26.5"
3
+ __version__ = "1.27.1"
@@ -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=monitors idle"
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 running")
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
- f"load:reload-sync({self_cmd} rows --refresh)" if from_cache else "",
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
- if not any(tok in known for tok in raw):
197
- raw.append("pick")
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+\s+monitors?\b|^\s*◯\s+\S",
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
- if _BG.search(screen):
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