claude-code-tools 1.26.4__tar.gz → 1.27.0__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 (138) hide show
  1. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/PKG-INFO +1 -1
  2. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/__init__.py +1 -1
  3. claude_code_tools-1.27.0/claude_code_tools/amux/activity.py +260 -0
  4. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/cli.py +52 -7
  5. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/detect.py +8 -2
  6. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/model.py +48 -1
  7. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/render.py +14 -0
  8. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/scan.py +2 -1
  9. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_process.py +5 -3
  10. claude_code_tools-1.27.0/claude_code_tools/codex_server_wait.py +59 -0
  11. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/pyproject.toml +2 -2
  12. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/.gitignore +0 -0
  13. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/LICENSE +0 -0
  14. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/README.md +0 -0
  15. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/action_rpc.py +0 -0
  16. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  17. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  18. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  19. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/config.py +0 -0
  20. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  21. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  22. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  23. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  24. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  25. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  26. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  27. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  28. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  29. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/aichat.py +0 -0
  30. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/__init__.py +0 -0
  31. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/__main__.py +0 -0
  32. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/amux/cache.py +0 -0
  33. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/claude_continue.py +0 -0
  34. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/cli_passthrough.py +0 -0
  35. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_app_server_rpc.py +0 -0
  36. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_continue.py +0 -0
  37. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server.py +0 -0
  38. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_callback.py +0 -0
  39. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_cli.py +0 -0
  40. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  41. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_generation.py +0 -0
  42. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_legacy.py +0 -0
  43. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_models.py +0 -0
  44. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_reservation.py +0 -0
  45. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_resume.py +0 -0
  46. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_retry.py +0 -0
  47. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_reuse.py +0 -0
  48. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_state.py +0 -0
  49. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  50. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/codex_server_worker.py +0 -0
  51. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/config.py +0 -0
  52. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/csv2gsheet.py +0 -0
  53. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/delete_session.py +0 -0
  54. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/dotenv_vault.py +0 -0
  55. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/env_safe.py +0 -0
  56. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/export_all.py +0 -0
  57. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/export_claude_session.py +0 -0
  58. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/export_codex_session.py +0 -0
  59. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/export_session.py +0 -0
  60. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/find_claude_session.py +0 -0
  61. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/find_codex_session.py +0 -0
  62. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/find_original_session.py +0 -0
  63. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/find_session.py +0 -0
  64. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  65. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/fix_session.py +0 -0
  66. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/gdoc2docx.py +0 -0
  67. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/gdoc2md.py +0 -0
  68. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/github_wake.py +0 -0
  69. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/github_watch_daemon.py +0 -0
  70. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/github_watch_store.py +0 -0
  71. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/gsheet2csv.py +0 -0
  72. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/helper_sessions.py +0 -0
  73. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/issue_reply_delivery.py +0 -0
  74. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/legacy_session_wrappers.py +0 -0
  75. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/md2gdoc.py +0 -0
  76. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/move_account.py +0 -0
  77. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/__init__.py +0 -0
  78. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/cli.py +0 -0
  79. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/hooks.py +0 -0
  80. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/migrations.py +0 -0
  81. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/models.py +0 -0
  82. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  83. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/store.py +0 -0
  84. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/msg/watcher.py +0 -0
  85. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/node_menu_ui.py +0 -0
  86. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/port_claude_noise.py +0 -0
  87. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/port_claude_to_codex.py +0 -0
  88. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/port_codex_flatten.py +0 -0
  89. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/port_codex_to_claude.py +0 -0
  90. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/port_render.py +0 -0
  91. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/port_service.py +0 -0
  92. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/resolve_session.py +0 -0
  93. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/resolve_session_names.py +0 -0
  94. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/resolve_session_render.py +0 -0
  95. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/search_index.py +0 -0
  96. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/session_cli_resolution.py +0 -0
  97. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/session_lineage.py +0 -0
  98. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/session_menu.py +0 -0
  99. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/session_menu_cli.py +0 -0
  100. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/session_resolution.py +0 -0
  101. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/session_selection.py +0 -0
  102. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/session_utils.py +0 -0
  103. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/smart_trim.py +0 -0
  104. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/smart_trim_core.py +0 -0
  105. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/tmux_cli_controller.py +0 -0
  106. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  107. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  108. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/trim_in_place.py +0 -0
  109. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/trim_session.py +0 -0
  110. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/trim_session_claude.py +0 -0
  111. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/trim_session_codex.py +0 -0
  112. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli.py +0 -0
  113. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  114. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  115. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  116. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  117. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  118. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  119. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  120. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  121. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_processes.py +0 -0
  122. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_runs.py +0 -0
  123. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_store_io.py +0 -0
  124. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/claude_code_tools/workflow_validation.py +0 -0
  125. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/agent-tunnel-spec.md +0 -0
  126. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/claude-code-tmux-tutorials.md +0 -0
  127. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/dot-zshrc.md +0 -0
  128. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/find-claude-session.md +0 -0
  129. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/intercom-spec.md +0 -0
  130. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/lmsh.md +0 -0
  131. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/local-llm-setup.md +0 -0
  132. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/tmux-cli-instructions.md +0 -0
  133. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/docs/vault-documentation.md +0 -0
  134. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/hatch_build.py +0 -0
  135. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/node_ui/action_config.js +0 -0
  136. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/node_ui/menu.js +0 -0
  137. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/node_ui/package-lock.json +0 -0
  138. {claude_code_tools-1.26.4 → claude_code_tools-1.27.0}/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.4
3
+ Version: 1.27.0
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.4"
3
+ __version__ = "1.27.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"
@@ -24,7 +24,7 @@ from .model import Agent
24
24
 
25
25
  _FZF_HEADER = (
26
26
  "enter=jump ctrl-r=refresh esc=quit "
27
- "input=asking you busy=working bg=monitors idle"
27
+ "input=asking you busy=working bg=background age=last submitted input"
28
28
  )
29
29
 
30
30
 
@@ -44,11 +44,27 @@ def _agents_for_display(max_age: float) -> tuple[list[Agent], bool]:
44
44
  return _fresh(), False
45
45
 
46
46
 
47
+ def _display(agents: list[Agent], args: argparse.Namespace) -> list[Agent]:
48
+ """Refresh ages even for cached rows, then apply the requested view."""
49
+ for agent in agents:
50
+ agent.classify_inactivity(args.dormant_hours)
51
+ if args.dormant:
52
+ agents = [a for a in agents if a.inactivity == "dormant"]
53
+ if args.sort == "oldest":
54
+ agents = sorted(agents, key=lambda a: (
55
+ a.last_input_at is None,
56
+ a.last_input_at if a.last_input_at is not None else 0,
57
+ a.pane,
58
+ ))
59
+ return agents
60
+
61
+
47
62
  def cmd_list(args: argparse.Namespace) -> int:
48
63
  """Print every live agent as a table or JSON."""
49
64
  agents = cache.read(max_age=args.max_age)[0] if args.cached else _fresh()
50
65
  if not agents and args.cached:
51
66
  agents = _fresh()
67
+ agents = _display(agents, args)
52
68
  if args.json:
53
69
  print(render.as_json(agents))
54
70
  else:
@@ -67,7 +83,7 @@ def cmd_scan(args: argparse.Namespace) -> int:
67
83
  def cmd_rows(args: argparse.Namespace) -> int:
68
84
  """Emit picker rows (internal: used by fzf's reload binding)."""
69
85
  agents = _fresh() if args.refresh else _agents_for_display(args.max_age)[0]
70
- print(render.picker_lines(agents, colour=True))
86
+ print(render.picker_lines(_display(agents, args), colour=True))
71
87
  return 0
72
88
 
73
89
 
@@ -81,18 +97,29 @@ def cmd_pick(args: argparse.Namespace) -> int:
81
97
  return 1
82
98
 
83
99
  agents, from_cache = _agents_for_display(args.max_age)
100
+ agents = _display(agents, args)
101
+ if not agents and from_cache:
102
+ agents = _display(_fresh(), args)
103
+ from_cache = False
84
104
  if not agents:
85
- print("no agents running")
105
+ print("no matching agents")
86
106
  return 0
87
107
 
88
108
  # fzf runs bind commands through a shell, so the interpreter path must be
89
109
  # quoted: a venv at "/tmp/my venv/bin/python" would otherwise run "/tmp/my".
90
110
  self_cmd = f"{shlex.quote(sys.executable)} -m claude_code_tools.amux"
111
+ view_flags = (
112
+ f" --dormant-hours {args.dormant_hours} --sort {args.sort}"
113
+ + (" --dormant" if args.dormant else "")
114
+ )
91
115
  binds = [
92
- f"ctrl-r:reload({self_cmd} rows --refresh)",
116
+ f"ctrl-r:reload({self_cmd} rows --refresh{view_flags})",
93
117
  # Opening on cached rows is what makes this instant; refresh the
94
118
  # 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 "",
119
+ (
120
+ f"load:reload-sync({self_cmd} rows --refresh{view_flags})"
121
+ if from_cache else ""
122
+ ),
96
123
  ]
97
124
  cmd = [
98
125
  "fzf",
@@ -182,6 +209,24 @@ def build_parser() -> argparse.ArgumentParser:
182
209
  rows.add_argument("--refresh", action="store_true")
183
210
  rows.set_defaults(func=cmd_rows)
184
211
 
212
+ for command in (pick, lst, rows, scan_cmd):
213
+ command.add_argument(
214
+ "--max-age", type=_finite_seconds, default=argparse.SUPPRESS,
215
+ help="seconds a cached scan stays usable (default: 30)",
216
+ )
217
+ for command in (pick, lst, rows):
218
+ command.add_argument(
219
+ "--dormant", action="store_true",
220
+ help="show waiting agents with old known submitted input only",
221
+ )
222
+ command.add_argument(
223
+ "--dormant-hours", type=_finite_seconds, default=72.0,
224
+ help="input age in hours above which waiting agents are dormant (72)",
225
+ )
226
+ command.add_argument(
227
+ "--sort", choices=("state", "oldest"), default="state",
228
+ help="sort by current state or oldest known submitted input",
229
+ )
185
230
  return parser
186
231
 
187
232
 
@@ -193,8 +238,8 @@ def main(argv: list[str] | None = None) -> int:
193
238
  # only ["pick"] -- reparsing dropped global options, so `amux --max-age 0`
194
239
  # silently used the 30s default.
195
240
  known = {"pick", "list", "scan", "rows"}
196
- if not any(tok in known for tok in raw):
197
- raw.append("pick")
241
+ if not any(tok in known | {"-h", "--help"} for tok in raw):
242
+ raw.insert(0, "pick")
198
243
  args = parser.parse_args(raw)
199
244
  if not scan.tmux_available():
200
245
  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
 
@@ -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
 
@@ -33,6 +33,9 @@ from claude_code_tools.codex_server_models import (
33
33
  )
34
34
 
35
35
 
36
+ from claude_code_tools.codex_server_wait import child_exited_without_reaping
37
+
38
+
36
39
  DIAGNOSTIC_OUTPUT_MAX_BYTES = 64 * 1024
37
40
  _LINUX_BOOT_ID = "/proc/sys/kernel/random/boot_id"
38
41
  _PROC_PIDTBSDINFO = 3
@@ -203,16 +206,15 @@ def _wait_process_exit_without_reaping(
203
206
  deadline: float,
204
207
  ) -> bool:
205
208
  """Observe child exit while preserving its PID through group cleanup."""
206
- wait_flags = os.WEXITED | os.WNOHANG | os.WNOWAIT
207
209
  while True:
208
210
  try:
209
- result = os.waitid(os.P_PID, process.pid, wait_flags)
211
+ exited = child_exited_without_reaping(process.pid)
210
212
  except ChildProcessError:
211
213
  process.poll()
212
214
  return False
213
215
  except InterruptedError:
214
216
  continue
215
- if result is not None:
217
+ if exited:
216
218
  return True
217
219
  remaining = deadline - time.monotonic()
218
220
  if remaining <= 0:
@@ -0,0 +1,59 @@
1
+ """Non-reaping child-exit observation across supported POSIX platforms."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import os
7
+ import sys
8
+
9
+
10
+ class _DarwinSiginfo(ctypes.Structure):
11
+ """Darwin siginfo_t from sys/signal.h (including reserved ABI fields)."""
12
+
13
+ _fields_ = [
14
+ ("si_signo", ctypes.c_int),
15
+ ("si_errno", ctypes.c_int),
16
+ ("si_code", ctypes.c_int),
17
+ ("si_pid", ctypes.c_int),
18
+ ("si_uid", ctypes.c_uint),
19
+ ("si_status", ctypes.c_int),
20
+ ("si_addr", ctypes.c_void_p),
21
+ ("si_value", ctypes.c_void_p),
22
+ ("si_band", ctypes.c_long),
23
+ ("padding", ctypes.c_ulong * 7),
24
+ ]
25
+
26
+
27
+ def child_exited_without_reaping(pid: int) -> bool:
28
+ """Observe an exited child while keeping its PID reserved for cleanup.
29
+
30
+ Args:
31
+ pid: PID of a direct child that this caller has not reaped.
32
+
33
+ Returns:
34
+ Whether the child has exited. A running child returns False.
35
+
36
+ Raises:
37
+ OSError: The OS cannot observe this child, including ECHILD or EINTR.
38
+ NotImplementedError: No supported non-reaping API is available.
39
+ """
40
+ flags = os.WEXITED | os.WNOHANG | os.WNOWAIT
41
+ waitid = getattr(os, "waitid", None)
42
+ if waitid is not None:
43
+ return waitid(os.P_PID, pid, flags) is not None
44
+ if sys.platform != "darwin":
45
+ raise NotImplementedError("non-reaping child observation needs waitid")
46
+
47
+ # Some macOS Python builds omit os.waitid although libSystem provides it.
48
+ # P_PID is 1 in Darwin's sys/wait.h; Python may omit that constant too.
49
+ library = ctypes.CDLL(None, use_errno=True)
50
+ native_waitid = library.waitid
51
+ native_waitid.argtypes = [
52
+ ctypes.c_int, ctypes.c_uint, ctypes.POINTER(_DarwinSiginfo), ctypes.c_int
53
+ ]
54
+ native_waitid.restype = ctypes.c_int
55
+ info = _DarwinSiginfo()
56
+ if native_waitid(1, pid, ctypes.byref(info), flags) == -1:
57
+ error = ctypes.get_errno()
58
+ raise OSError(error, os.strerror(error))
59
+ return info.si_pid == pid
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-tools"
3
- version = "1.26.4"
3
+ version = "1.27.0"
4
4
  description = "Collection of tools for working with Claude Code"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -113,7 +113,7 @@ markers = [
113
113
 
114
114
  [tool.commitizen]
115
115
  name = "cz_conventional_commits"
116
- version = "1.26.4"
116
+ version = "1.27.0"
117
117
  tag_format = "v$version"
118
118
  version_files = [
119
119
  "pyproject.toml:version",