claude-code-tools 1.23.2__tar.gz → 1.25.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.
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/PKG-INFO +18 -2
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/README.md +17 -1
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/__init__.py +1 -1
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/aichat.py +41 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/__init__.py +5 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/__main__.py +6 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/cache.py +80 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/cli.py +206 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/detect.py +236 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/model.py +90 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/render.py +86 -0
- claude_code_tools-1.25.0/claude_code_tools/amux/scan.py +259 -0
- claude_code_tools-1.25.0/claude_code_tools/codex_app_server_rpc.py +372 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server.py +4 -7
- claude_code_tools-1.25.0/claude_code_tools/codex_server_callback.py +39 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_cli.py +16 -6
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_fingerprint.py +180 -42
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_models.py +1 -0
- claude_code_tools-1.25.0/claude_code_tools/codex_server_resume.py +275 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/export_session.py +8 -0
- claude_code_tools-1.25.0/claude_code_tools/github_wake.py +413 -0
- claude_code_tools-1.25.0/claude_code_tools/github_watch_daemon.py +486 -0
- claude_code_tools-1.25.0/claude_code_tools/github_watch_store.py +390 -0
- claude_code_tools-1.25.0/claude_code_tools/issue_reply_delivery.py +197 -0
- claude_code_tools-1.25.0/claude_code_tools/move_account.py +721 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/port_claude_to_codex.py +140 -7
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/port_codex_to_claude.py +286 -8
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/pyproject.toml +4 -2
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/.gitignore +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/LICENSE +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/action_rpc.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/config.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/store.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/claude_continue.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_continue.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_generation.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_legacy.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_process.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_reservation.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_retry.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_reuse.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_state.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_supervisor.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/codex_server_worker.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/config.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/csv2gsheet.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/delete_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/dotenv_vault.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/env_safe.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/export_all.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/export_claude_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/export_codex_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/find_claude_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/find_codex_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/find_original_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/find_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/fix_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/gdoc2docx.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/gdoc2md.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/gsheet2csv.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/md2gdoc.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/msg/__init__.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/msg/cli.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/msg/hooks.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/msg/models.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/msg/prompt_detect.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/msg/store.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/msg/watcher.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/node_menu_ui.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/port_claude_noise.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/port_codex_flatten.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/port_render.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/port_service.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/resolve_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/resolve_session_names.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/resolve_session_render.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/search_index.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/session_lineage.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/session_menu.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/session_menu_cli.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/session_resolution.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/session_utils.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/smart_trim.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/smart_trim_core.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/tmux_cli_controller.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/tmux_remote_controller.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/trim_in_place.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/trim_session.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/trim_session_claude.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/trim_session_codex.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_contract.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_projection.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_processes.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_runs.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_store_io.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/claude_code_tools/workflow_validation.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/agent-tunnel-spec.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/claude-code-tmux-tutorials.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/dot-zshrc.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/find-claude-session.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/intercom-spec.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/lmsh.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/local-llm-setup.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/msg-phase-a-plan.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/msg-plan-v2.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/tmux-cli-instructions.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/docs/vault-documentation.md +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/hatch_build.py +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/node_ui/action_config.js +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/node_ui/menu.js +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.0}/node_ui/package-lock.json +0 -0
- {claude_code_tools-1.23.2 → claude_code_tools-1.25.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.
|
|
3
|
+
Version: 1.25.0
|
|
4
4
|
Summary: Collection of tools for working with Claude Code
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.11
|
|
@@ -84,6 +84,11 @@ docs. Click a card below to jump to a feature, or
|
|
|
84
84
|
</tr>
|
|
85
85
|
<tr>
|
|
86
86
|
<td align="center">
|
|
87
|
+
<a href="https://pchalasani.github.io/claude-code-tools/tools/amux/">
|
|
88
|
+
<img src="assets/card-amux.svg" alt="amux" width="200"/>
|
|
89
|
+
</a>
|
|
90
|
+
</td>
|
|
91
|
+
<td align="center">
|
|
87
92
|
<a href="https://pchalasani.github.io/claude-code-tools/tools/agent-tunnel/">
|
|
88
93
|
<img src="assets/card-agent-tunnel.svg" alt="agent-tunnel" width="200"/>
|
|
89
94
|
</a>
|
|
@@ -152,8 +157,19 @@ docs. Click a card below to jump to a feature, or
|
|
|
152
157
|
</tr>
|
|
153
158
|
<tr>
|
|
154
159
|
<td align="center">
|
|
160
|
+
<a href="https://pchalasani.github.io/claude-code-tools/tools/aichat/port/">
|
|
161
|
+
<img src="assets/card-session-port.svg" alt="session porting" width="200"/>
|
|
162
|
+
</a>
|
|
163
|
+
</td>
|
|
164
|
+
<td align="center">
|
|
155
165
|
<a href="https://pchalasani.github.io/claude-code-tools/guides/claude-to-codex/">
|
|
156
|
-
<img src="assets/card-claude-to-codex.svg" alt="
|
|
166
|
+
<img src="assets/card-claude-to-codex.svg" alt="Codex dynamic workflows" width="200"/>
|
|
167
|
+
</a>
|
|
168
|
+
</td>
|
|
169
|
+
<td align="center">
|
|
170
|
+
<a href="https://pchalasani.github.io/claude-code-tools/tools/github-wake/">
|
|
171
|
+
<img src="assets/card-github-watch.svg"
|
|
172
|
+
alt="github-watch: wake on github comment" width="200"/>
|
|
157
173
|
</a>
|
|
158
174
|
</td>
|
|
159
175
|
</tr>
|
|
@@ -58,6 +58,11 @@ docs. Click a card below to jump to a feature, or
|
|
|
58
58
|
</tr>
|
|
59
59
|
<tr>
|
|
60
60
|
<td align="center">
|
|
61
|
+
<a href="https://pchalasani.github.io/claude-code-tools/tools/amux/">
|
|
62
|
+
<img src="assets/card-amux.svg" alt="amux" width="200"/>
|
|
63
|
+
</a>
|
|
64
|
+
</td>
|
|
65
|
+
<td align="center">
|
|
61
66
|
<a href="https://pchalasani.github.io/claude-code-tools/tools/agent-tunnel/">
|
|
62
67
|
<img src="assets/card-agent-tunnel.svg" alt="agent-tunnel" width="200"/>
|
|
63
68
|
</a>
|
|
@@ -126,8 +131,19 @@ docs. Click a card below to jump to a feature, or
|
|
|
126
131
|
</tr>
|
|
127
132
|
<tr>
|
|
128
133
|
<td align="center">
|
|
134
|
+
<a href="https://pchalasani.github.io/claude-code-tools/tools/aichat/port/">
|
|
135
|
+
<img src="assets/card-session-port.svg" alt="session porting" width="200"/>
|
|
136
|
+
</a>
|
|
137
|
+
</td>
|
|
138
|
+
<td align="center">
|
|
129
139
|
<a href="https://pchalasani.github.io/claude-code-tools/guides/claude-to-codex/">
|
|
130
|
-
<img src="assets/card-claude-to-codex.svg" alt="
|
|
140
|
+
<img src="assets/card-claude-to-codex.svg" alt="Codex dynamic workflows" width="200"/>
|
|
141
|
+
</a>
|
|
142
|
+
</td>
|
|
143
|
+
<td align="center">
|
|
144
|
+
<a href="https://pchalasani.github.io/claude-code-tools/tools/github-wake/">
|
|
145
|
+
<img src="assets/card-github-watch.svg"
|
|
146
|
+
alt="github-watch: wake on github comment" width="200"/>
|
|
131
147
|
</a>
|
|
132
148
|
</td>
|
|
133
149
|
</tr>
|
|
@@ -1645,6 +1645,47 @@ def move_session(
|
|
|
1645
1645
|
console.print(f" [cyan]{resume_cmd}[/cyan]")
|
|
1646
1646
|
|
|
1647
1647
|
|
|
1648
|
+
@main.command("move-account")
|
|
1649
|
+
@click.argument("session", required=True)
|
|
1650
|
+
@click.option("--to", "to_home", required=True,
|
|
1651
|
+
help="Target config dir (account), e.g. ~/.claude or ~/.codex")
|
|
1652
|
+
@click.option("--from", "from_home", default=None,
|
|
1653
|
+
help="Source config dir (default: search all local homes — "
|
|
1654
|
+
"env-var home, ~/.claude / ~/.codex, and "
|
|
1655
|
+
"~/.claude-* / ~/.codex-* siblings)")
|
|
1656
|
+
@click.option("--agent",
|
|
1657
|
+
type=click.Choice(["claude", "codex"], case_sensitive=False),
|
|
1658
|
+
default=None,
|
|
1659
|
+
help="Agent type (auto-detected from the config dirs "
|
|
1660
|
+
"if not specified)")
|
|
1661
|
+
@click.option("--keep", is_flag=True,
|
|
1662
|
+
help="Copy instead of move (leave source account untouched)")
|
|
1663
|
+
def move_account(session, to_home, from_home, agent, keep):
|
|
1664
|
+
"""Move a session to a different account (config dir).
|
|
1665
|
+
|
|
1666
|
+
Accounts are config dirs like ~/.claude vs ~/.claude-rja (or
|
|
1667
|
+
~/.codex vs ~/.codex-rja), normally selected via CLAUDE_CONFIG_DIR
|
|
1668
|
+
/ CODEX_HOME. The session stays in the same project. Claude moves
|
|
1669
|
+
relocate the transcript and its sidecar dir (subagents,
|
|
1670
|
+
tool-results, workflows); Codex moves relocate the rollout file
|
|
1671
|
+
and its thread-name entries in session_index.jsonl.
|
|
1672
|
+
|
|
1673
|
+
SESSION may be a session UUID (full or partial), a Claude session
|
|
1674
|
+
name assigned with /rename, or a Codex thread name.
|
|
1675
|
+
|
|
1676
|
+
\b
|
|
1677
|
+
Examples:
|
|
1678
|
+
aichat move-account cowrite-fable-21jul2026 \\
|
|
1679
|
+
--from ~/.claude-rja --to ~/.claude
|
|
1680
|
+
aichat move-account af574e84 --to ~/.claude-rja --keep
|
|
1681
|
+
aichat move-account my-codex-thread \\
|
|
1682
|
+
--from ~/.codex-rja --to ~/.codex
|
|
1683
|
+
"""
|
|
1684
|
+
from claude_code_tools.move_account import run_move_account
|
|
1685
|
+
|
|
1686
|
+
run_move_account(session, to_home, from_home, keep, agent)
|
|
1687
|
+
|
|
1688
|
+
|
|
1648
1689
|
def _protect_surrogateescape_bytes(
|
|
1649
1690
|
line: str,
|
|
1650
1691
|
*,
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Cache of the last scan, so the picker opens instantly.
|
|
2
|
+
|
|
3
|
+
The picker shows cached rows immediately and kicks off a fresh scan in the
|
|
4
|
+
background, replacing the list when it lands. With hundreds of panes, a cold
|
|
5
|
+
scan is noticeable; a warm one is not.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .model import Agent
|
|
17
|
+
|
|
18
|
+
_DEFAULT = Path.home() / ".cache" / "cc-tools" / "amux.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_path() -> Path:
|
|
22
|
+
"""Location of the cache file (``AMUX_CACHE`` overrides)."""
|
|
23
|
+
override = os.environ.get("AMUX_CACHE")
|
|
24
|
+
return Path(override) if override else _DEFAULT
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read(max_age: float | None = None) -> tuple[list[Agent], float]:
|
|
28
|
+
"""Load cached agents.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
max_age: If set, return an empty list when the cache is older than
|
|
32
|
+
this many seconds.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
``(agents, age_seconds)``; ``age_seconds`` is ``-1`` when no usable
|
|
36
|
+
cache exists.
|
|
37
|
+
"""
|
|
38
|
+
path = cache_path()
|
|
39
|
+
try:
|
|
40
|
+
raw = json.loads(path.read_text())
|
|
41
|
+
except (OSError, ValueError):
|
|
42
|
+
return [], -1.0
|
|
43
|
+
|
|
44
|
+
# Syntactically valid JSON can still be the wrong shape (hand-edited file,
|
|
45
|
+
# a truncated write from an older version). Never crash over a cache.
|
|
46
|
+
try:
|
|
47
|
+
stamp = float(raw.get("time", 0))
|
|
48
|
+
age = time.time() - stamp
|
|
49
|
+
# Python's json accepts the NaN literal, and every comparison against
|
|
50
|
+
# nan is False -- so a NaN age passed both the negative check and the
|
|
51
|
+
# max_age check, making the cache permanently "fresh".
|
|
52
|
+
if not math.isfinite(age) or age < 0:
|
|
53
|
+
return [], -1.0
|
|
54
|
+
if max_age is not None and age > max_age:
|
|
55
|
+
return [], age
|
|
56
|
+
parsed = (
|
|
57
|
+
Agent.from_dict(d) for d in raw.get("agents", []) if isinstance(d, dict)
|
|
58
|
+
)
|
|
59
|
+
agents = [a for a in parsed if a is not None]
|
|
60
|
+
except (AttributeError, TypeError, ValueError):
|
|
61
|
+
return [], -1.0
|
|
62
|
+
return agents, age
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def write(agents: list[Agent]) -> None:
|
|
66
|
+
"""Persist *agents* atomically, creating the cache directory if needed."""
|
|
67
|
+
path = cache_path()
|
|
68
|
+
try:
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
payload = {
|
|
71
|
+
"time": time.time(),
|
|
72
|
+
"agents": [a.to_dict() for a in agents],
|
|
73
|
+
}
|
|
74
|
+
# Per-process temp name: two concurrent `amux scan` runs sharing one
|
|
75
|
+
# ".tmp" would race, and the loser died on a missing file.
|
|
76
|
+
tmp = path.with_suffix(f".{os.getpid()}.tmp")
|
|
77
|
+
tmp.write_text(json.dumps(payload))
|
|
78
|
+
tmp.replace(path)
|
|
79
|
+
except OSError:
|
|
80
|
+
pass # a missing cache only costs speed, never correctness
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""amux -- see and jump between the coding agents running in your tmux panes.
|
|
2
|
+
|
|
3
|
+
amux # interactive picker (fzf): jump to an agent
|
|
4
|
+
amux list # table of every live agent
|
|
5
|
+
amux list --json # machine-readable
|
|
6
|
+
amux scan # refresh the cache (for cron / tmux hooks)
|
|
7
|
+
|
|
8
|
+
The picker opens on cached rows and refreshes in the background, so it is
|
|
9
|
+
instant even with hundreds of panes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from . import cache, render, scan
|
|
23
|
+
from .model import Agent
|
|
24
|
+
|
|
25
|
+
_FZF_HEADER = (
|
|
26
|
+
"enter=jump ctrl-r=refresh esc=quit "
|
|
27
|
+
"input=asking you busy=working bg=monitors idle"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fresh(write_cache: bool = True) -> list[Agent]:
|
|
32
|
+
"""Scan live panes and refresh the cache."""
|
|
33
|
+
agents = scan.scan()
|
|
34
|
+
if write_cache:
|
|
35
|
+
cache.write(agents)
|
|
36
|
+
return agents
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _agents_for_display(max_age: float) -> tuple[list[Agent], bool]:
|
|
40
|
+
"""Return agents to show now, plus whether they came from cache."""
|
|
41
|
+
cached, age = cache.read(max_age=max_age)
|
|
42
|
+
if cached:
|
|
43
|
+
return cached, True
|
|
44
|
+
return _fresh(), False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
48
|
+
"""Print every live agent as a table or JSON."""
|
|
49
|
+
agents = cache.read(max_age=args.max_age)[0] if args.cached else _fresh()
|
|
50
|
+
if not agents and args.cached:
|
|
51
|
+
agents = _fresh()
|
|
52
|
+
if args.json:
|
|
53
|
+
print(render.as_json(agents))
|
|
54
|
+
else:
|
|
55
|
+
colour = sys.stdout.isatty() and not args.no_color
|
|
56
|
+
print(render.table(agents, colour=colour))
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
61
|
+
"""Refresh the cache and report what was found."""
|
|
62
|
+
agents = _fresh()
|
|
63
|
+
print(f"cached {len(agents)} agents -> {cache.cache_path()}")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cmd_rows(args: argparse.Namespace) -> int:
|
|
68
|
+
"""Emit picker rows (internal: used by fzf's reload binding)."""
|
|
69
|
+
agents = _fresh() if args.refresh else _agents_for_display(args.max_age)[0]
|
|
70
|
+
print(render.picker_lines(agents, colour=True))
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_pick(args: argparse.Namespace) -> int:
|
|
75
|
+
"""Interactive picker; jumps to the chosen agent's pane."""
|
|
76
|
+
if not shutil.which("fzf"):
|
|
77
|
+
print("amux: fzf not found (brew install fzf)", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
80
|
+
print("amux: no tty -- use 'amux list' for scripted output", file=sys.stderr)
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
agents, from_cache = _agents_for_display(args.max_age)
|
|
84
|
+
if not agents:
|
|
85
|
+
print("no agents running")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
# fzf runs bind commands through a shell, so the interpreter path must be
|
|
89
|
+
# quoted: a venv at "/tmp/my venv/bin/python" would otherwise run "/tmp/my".
|
|
90
|
+
self_cmd = f"{shlex.quote(sys.executable)} -m claude_code_tools.amux"
|
|
91
|
+
binds = [
|
|
92
|
+
f"ctrl-r:reload({self_cmd} rows --refresh)",
|
|
93
|
+
# Opening on cached rows is what makes this instant; refresh the
|
|
94
|
+
# 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 "",
|
|
96
|
+
]
|
|
97
|
+
cmd = [
|
|
98
|
+
"fzf",
|
|
99
|
+
"--ansi",
|
|
100
|
+
"--no-sort",
|
|
101
|
+
# Field 1 is the pane target, hidden from view; see picker_lines().
|
|
102
|
+
"--delimiter",
|
|
103
|
+
"\t",
|
|
104
|
+
"--with-nth",
|
|
105
|
+
"2..",
|
|
106
|
+
"--header",
|
|
107
|
+
_FZF_HEADER,
|
|
108
|
+
"--preview",
|
|
109
|
+
"tmux capture-pane -ep -S -200 -t {1} | grep '[^[:space:]]' | tail -60",
|
|
110
|
+
"--preview-window",
|
|
111
|
+
"right:55%:wrap:follow",
|
|
112
|
+
"--prompt",
|
|
113
|
+
"agent> ",
|
|
114
|
+
]
|
|
115
|
+
for bind in binds:
|
|
116
|
+
if bind:
|
|
117
|
+
cmd += ["--bind", bind]
|
|
118
|
+
|
|
119
|
+
# Capture ONLY stdout. capture_output=True also pipes stderr, which is
|
|
120
|
+
# where fzf draws its interface -- the UI never reaches the terminal and
|
|
121
|
+
# the picker looks hung, waiting for keys against an invisible prompt.
|
|
122
|
+
# (fzf reads keystrokes from /dev/tty, so feeding the list on stdin is
|
|
123
|
+
# fine.)
|
|
124
|
+
proc = subprocess.run(
|
|
125
|
+
cmd,
|
|
126
|
+
input=render.picker_lines(agents),
|
|
127
|
+
stdout=subprocess.PIPE,
|
|
128
|
+
text=True,
|
|
129
|
+
)
|
|
130
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
pane = render.pane_from_selection(proc.stdout)
|
|
134
|
+
if not pane:
|
|
135
|
+
return 0
|
|
136
|
+
scan.switch_to(pane)
|
|
137
|
+
return 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _finite_seconds(value: str) -> float:
|
|
141
|
+
"""Parse a --max-age value, rejecting nan/inf.
|
|
142
|
+
|
|
143
|
+
argparse's ``type=float`` happily accepts "nan", and every comparison
|
|
144
|
+
against NaN is False -- so ``--max-age nan`` disabled cache expiry
|
|
145
|
+
entirely and served arbitrarily stale data.
|
|
146
|
+
"""
|
|
147
|
+
seconds = float(value)
|
|
148
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
149
|
+
raise argparse.ArgumentTypeError(
|
|
150
|
+
f"must be a non-negative finite number of seconds, got {value!r}"
|
|
151
|
+
)
|
|
152
|
+
return seconds
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
156
|
+
"""Construct the argument parser."""
|
|
157
|
+
parser = argparse.ArgumentParser(
|
|
158
|
+
prog="amux",
|
|
159
|
+
description="See and jump between coding agents running in tmux panes.",
|
|
160
|
+
)
|
|
161
|
+
parser.add_argument(
|
|
162
|
+
"--max-age",
|
|
163
|
+
type=_finite_seconds,
|
|
164
|
+
default=30.0,
|
|
165
|
+
help="seconds a cached scan stays usable (default: 30)",
|
|
166
|
+
)
|
|
167
|
+
sub = parser.add_subparsers(dest="command")
|
|
168
|
+
|
|
169
|
+
pick = sub.add_parser("pick", help="interactive picker (default)")
|
|
170
|
+
pick.set_defaults(func=cmd_pick)
|
|
171
|
+
|
|
172
|
+
lst = sub.add_parser("list", help="print all live agents")
|
|
173
|
+
lst.add_argument("--json", action="store_true", help="machine-readable output")
|
|
174
|
+
lst.add_argument("--cached", action="store_true", help="use the cache if fresh")
|
|
175
|
+
lst.add_argument("--no-color", action="store_true", help="disable colour")
|
|
176
|
+
lst.set_defaults(func=cmd_list)
|
|
177
|
+
|
|
178
|
+
scan_cmd = sub.add_parser("scan", help="refresh the cache")
|
|
179
|
+
scan_cmd.set_defaults(func=cmd_scan)
|
|
180
|
+
|
|
181
|
+
rows = sub.add_parser("rows", help=argparse.SUPPRESS)
|
|
182
|
+
rows.add_argument("--refresh", action="store_true")
|
|
183
|
+
rows.set_defaults(func=cmd_rows)
|
|
184
|
+
|
|
185
|
+
return parser
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main(argv: list[str] | None = None) -> int:
|
|
189
|
+
"""Entry point for the ``amux`` console script."""
|
|
190
|
+
parser = build_parser()
|
|
191
|
+
raw = list(sys.argv[1:] if argv is None else argv)
|
|
192
|
+
# Default to the picker, but insert the subcommand rather than reparsing
|
|
193
|
+
# only ["pick"] -- reparsing dropped global options, so `amux --max-age 0`
|
|
194
|
+
# silently used the 30s default.
|
|
195
|
+
known = {"pick", "list", "scan", "rows"}
|
|
196
|
+
if not any(tok in known for tok in raw):
|
|
197
|
+
raw.append("pick")
|
|
198
|
+
args = parser.parse_args(raw)
|
|
199
|
+
if not scan.tmux_available():
|
|
200
|
+
print("amux: no tmux server running", file=sys.stderr)
|
|
201
|
+
return 1
|
|
202
|
+
return int(args.func(args) or 0)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Screen-scraping heuristics: what harness is this, and what is it doing.
|
|
2
|
+
|
|
3
|
+
Everything here is deliberately pure (string in, verdict out) so it can be
|
|
4
|
+
unit-tested against captured fixtures without a live tmux server.
|
|
5
|
+
|
|
6
|
+
Why screen-scraping at all: ``pane_current_command`` is useless for
|
|
7
|
+
identifying these harnesses -- Claude Code reports a bare version string like
|
|
8
|
+
``2.1.220`` because it runs a versioned binary, and Codex reports ``node``.
|
|
9
|
+
Process *argv* identifies the harness reliably; the *screen* is the only place
|
|
10
|
+
that reveals what it is currently doing.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
from .model import Kind, State
|
|
18
|
+
|
|
19
|
+
# --- harness identification (from the pane's child process argv) -----------
|
|
20
|
+
|
|
21
|
+
#: Interpreters that launch an agent via a script path rather than being the
|
|
22
|
+
#: agent themselves (Codex ships as JS run under node).
|
|
23
|
+
_INTERPRETERS = {"node", "node.exe", "python", "python3", "bun", "deno"}
|
|
24
|
+
|
|
25
|
+
#: Script-path fragments that identify a harness when run under an interpreter.
|
|
26
|
+
_CODEX_PATH = re.compile(r"@openai/codex|/codex(\.js|-cli)?\b")
|
|
27
|
+
_CLAUDE_PATH = re.compile(r"/claude(\.js|-code)?\b|/\.local/share/claude/")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _basename(token: str) -> str:
|
|
31
|
+
"""Executable name from an argv[0] token."""
|
|
32
|
+
return token.rsplit("/", 1)[-1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def classify_argv(argv: str) -> Kind | None:
|
|
36
|
+
"""Identify the harness from a single process command line.
|
|
37
|
+
|
|
38
|
+
Only the EXECUTABLE decides -- argv[0], or the script path when argv[0] is
|
|
39
|
+
an interpreter. Searching the whole command line matched arguments too, so
|
|
40
|
+
an ordinary ``rg claude .`` in a pane was listed as an idle Claude agent.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
argv: One process's command line.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
``"codex"``, ``"claude"``, or ``None`` when this is not an agent.
|
|
47
|
+
"""
|
|
48
|
+
for line in argv.splitlines():
|
|
49
|
+
tokens = line.split()
|
|
50
|
+
if not tokens:
|
|
51
|
+
continue
|
|
52
|
+
exe = _basename(tokens[0])
|
|
53
|
+
if exe in ("codex", "codex-cli"):
|
|
54
|
+
return "codex"
|
|
55
|
+
if exe in ("claude", "claude-code"):
|
|
56
|
+
return "claude"
|
|
57
|
+
# Versioned Claude binaries are named after the version (2.1.220).
|
|
58
|
+
if re.fullmatch(r"\d+\.\d+\.\d+", exe):
|
|
59
|
+
return "claude"
|
|
60
|
+
if exe in _INTERPRETERS and len(tokens) > 1:
|
|
61
|
+
script = tokens[1]
|
|
62
|
+
if _CODEX_PATH.search(script):
|
|
63
|
+
return "codex"
|
|
64
|
+
if _CLAUDE_PATH.search(script):
|
|
65
|
+
return "claude"
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# --- state detection (from the pane's visible screen) ----------------------
|
|
70
|
+
|
|
71
|
+
#: Claude renders AskUserQuestion and permission prompts as numbered choices.
|
|
72
|
+
_ASKING = re.compile(
|
|
73
|
+
r"❯\s*1\.|Do you want|Would you like|^\s*1\.\s*Yes|\(y/n\)|"
|
|
74
|
+
r"Select an option|Choose an option",
|
|
75
|
+
re.MULTILINE,
|
|
76
|
+
)
|
|
77
|
+
#: The MAIN agent is mid-turn. Two forms seen live:
|
|
78
|
+
#: "✻ Cooked for 14m 17s · esc to interrupt"
|
|
79
|
+
#: "· Razzmatazzing… (1m 42s · ↓ 4.8k tokens · thought for 14s)"
|
|
80
|
+
#: The second has no "esc to interrupt" at all, so matching only that phrase
|
|
81
|
+
#: reported an actively working pane as idle. The parenthesised
|
|
82
|
+
#: elapsed-time-plus-token-counter is what distinguishes it from a subagent
|
|
83
|
+
#: row, which carries the same counter WITHOUT parentheses.
|
|
84
|
+
_BUSY = re.compile(
|
|
85
|
+
r"esc to interrupt|\(\d+[ms][^)]*↓[^)]*tokens",
|
|
86
|
+
re.IGNORECASE,
|
|
87
|
+
)
|
|
88
|
+
#: Background work. Two forms, both taken from live panes:
|
|
89
|
+
#: "✻ Baked for 16m 24s · 1 monitor still running"
|
|
90
|
+
#: " ◯ cache-incremental-evidence Grepping ... 1m 16s · ↓ 873.5k tokens"
|
|
91
|
+
#: The second is Claude's subagent tree: ◯ marks a subagent still RUNNING
|
|
92
|
+
#: (✓ marks a finished one, and ⏺ is ordinary assistant output, so neither of
|
|
93
|
+
#: those can be used). Without this a pane with a working subagent but a free
|
|
94
|
+
#: main prompt reported as idle.
|
|
95
|
+
_BG = re.compile(
|
|
96
|
+
r"\b\d+\s+monitors?\b|^\s*◯\s+\S",
|
|
97
|
+
re.MULTILINE,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
#: Codex chrome to ignore when looking for its last content line.
|
|
101
|
+
_CODEX_CHROME = re.compile(
|
|
102
|
+
r"^\s*[›❯]|·|gpt-[\d.]|^\s*─|esc to|tab to|^\s*$|^\s*[▌│]"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
#: A pending prompt is at the BOTTOM of the screen. Searching all retained
|
|
107
|
+
#: text made any earlier sentence containing e.g. "Would you like" pin the
|
|
108
|
+
#: pane to `input` until it scrolled off.
|
|
109
|
+
_PROMPT_TAIL_LINES = 12
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def detect_state(screen: str, kind: Kind) -> State:
|
|
113
|
+
"""Classify what the agent is doing from its visible screen.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
screen: The pane's captured text (escape sequences stripped).
|
|
117
|
+
kind: Which harness is running, from :func:`classify_argv`.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
One of ``input`` (waiting on the user), ``busy`` (mid-turn),
|
|
121
|
+
``bg`` (background monitors running), or ``idle``.
|
|
122
|
+
"""
|
|
123
|
+
tail_lines = screen.splitlines()[-_PROMPT_TAIL_LINES:]
|
|
124
|
+
tail = "\n".join(tail_lines)
|
|
125
|
+
# An ANSWERED prompt keeps its choices on screen, with Claude's footer
|
|
126
|
+
# rendered below them. Only treat a prompt as pending when nothing from
|
|
127
|
+
# the footer appears after it -- otherwise a just-answered
|
|
128
|
+
# AskUserQuestion stays flagged as blocking until it scrolls off.
|
|
129
|
+
if _ASKING.search(tail) and not _footer_below_prompt(tail_lines):
|
|
130
|
+
return "input"
|
|
131
|
+
# BUSY must be decided before the Codex question heuristic. A visible
|
|
132
|
+
# spinner is proof the agent is working, whereas the heuristic can only
|
|
133
|
+
# guess from an older question still on screen: Codex asks, you answer,
|
|
134
|
+
# it starts working -- and the question is still up there.
|
|
135
|
+
if _BUSY.search(screen):
|
|
136
|
+
return "busy"
|
|
137
|
+
if kind == "codex" and _codex_awaiting_answer(tail):
|
|
138
|
+
return "input"
|
|
139
|
+
if _BG.search(screen):
|
|
140
|
+
return "bg"
|
|
141
|
+
return "idle"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
#: Footer chrome Claude renders BELOW a completed exchange. Anchored to the
|
|
145
|
+
#: start of a line: an option in a PENDING prompt can quote this text
|
|
146
|
+
#: ("2. Explain why the status says bypass permissions on") and must not be
|
|
147
|
+
#: mistaken for the footer itself.
|
|
148
|
+
_FOOTER = re.compile(
|
|
149
|
+
r"^\s*("
|
|
150
|
+
r"⏵⏵|ctx [█░]|new task\?|✻|※" # Claude chrome
|
|
151
|
+
r"|gpt-[\w.\-]+\s|›" # Codex status line and input box
|
|
152
|
+
r")"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _footer_below_prompt(lines: list[str]) -> bool:
|
|
157
|
+
"""Whether harness footer chrome appears after the last prompt line."""
|
|
158
|
+
last_prompt = -1
|
|
159
|
+
for index, line in enumerate(lines):
|
|
160
|
+
if _ASKING.search(line):
|
|
161
|
+
last_prompt = index
|
|
162
|
+
if last_prompt < 0:
|
|
163
|
+
return False
|
|
164
|
+
return any(_FOOTER.search(line) for line in lines[last_prompt + 1 :])
|
|
165
|
+
|
|
166
|
+
def _codex_awaiting_answer(screen: str) -> bool:
|
|
167
|
+
"""Heuristic: Codex asked a question and stopped.
|
|
168
|
+
|
|
169
|
+
Codex has no structured prompt UI, so the only available signal is that
|
|
170
|
+
the last real content line -- ignoring the input box and status chrome --
|
|
171
|
+
ends in a question mark. This has false positives (an answer that merely
|
|
172
|
+
ends in a question) and misses (a question phrased as a statement).
|
|
173
|
+
|
|
174
|
+
Only the screen TAIL is considered, and callers must rule out "busy"
|
|
175
|
+
first: a question further up the scrollback says nothing about whether
|
|
176
|
+
the agent is still waiting for you.
|
|
177
|
+
"""
|
|
178
|
+
for line in reversed(screen.splitlines()):
|
|
179
|
+
if not line.strip() or _CODEX_CHROME.search(line):
|
|
180
|
+
continue
|
|
181
|
+
return line.rstrip().endswith("?")
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --- context extraction ----------------------------------------------------
|
|
186
|
+
|
|
187
|
+
_CODEX_STATUS = re.compile(r"(gpt-[\w.\-]+)\s+(\w+)?\s*·\s*([^·]+)·\s*([^·\n]+)")
|
|
188
|
+
_CLAUDE_MODEL = re.compile(
|
|
189
|
+
r"\b(opus[\w.\-\[\]]*|sonnet[\w.\-\[\]]*|haiku[\w.\-\[\]]*|fable[\w.\-\[\]]*)"
|
|
190
|
+
)
|
|
191
|
+
_SEPARATOR_NAME = re.compile(r"─\s([A-Za-z0-9][A-Za-z0-9._-]{3,})\s─")
|
|
192
|
+
_ARGV_NAME = re.compile(r"--(?:resume|name)[= ]([^\s]+)")
|
|
193
|
+
_SPINNER = re.compile(r"^[✳*⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\s]+")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def extract_model(screen: str, kind: Kind) -> str:
|
|
197
|
+
"""Pull the model identifier out of the harness footer, if present."""
|
|
198
|
+
if kind == "codex":
|
|
199
|
+
match = _CODEX_STATUS.search(screen)
|
|
200
|
+
return match.group(1) if match else ""
|
|
201
|
+
match = _CLAUDE_MODEL.search(screen)
|
|
202
|
+
return match.group(1) if match else ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def extract_name(argv: str, pane_title: str, screen: str) -> str:
|
|
206
|
+
"""Best-effort agent session name, most reliable source first.
|
|
207
|
+
|
|
208
|
+
Order: the harness's own argv (``--resume``/``--name``), then the tmux
|
|
209
|
+
pane title (Claude sets it to ``✳ <name>``), then an on-screen separator
|
|
210
|
+
line. Codex rarely carries a name in any of these and returns ``""``.
|
|
211
|
+
"""
|
|
212
|
+
match = _ARGV_NAME.search(argv)
|
|
213
|
+
if match:
|
|
214
|
+
return match.group(1)
|
|
215
|
+
|
|
216
|
+
title = _SPINNER.sub("", pane_title).strip()
|
|
217
|
+
# Shell-set titles are paths, ~[dir] forms, or hostnames -- not names.
|
|
218
|
+
if title and not re.search(r"[/\[\]]|\.lan$|\.local$", title):
|
|
219
|
+
return title
|
|
220
|
+
|
|
221
|
+
names = _SEPARATOR_NAME.findall(screen)
|
|
222
|
+
return names[-1] if names else ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def extract_info(screen: str, kind: Kind) -> str:
|
|
226
|
+
"""One line of context for the list view (model, repo, branch, …)."""
|
|
227
|
+
if kind == "codex":
|
|
228
|
+
match = _CODEX_STATUS.search(screen)
|
|
229
|
+
if match:
|
|
230
|
+
parts = [p.strip() for p in match.groups() if p]
|
|
231
|
+
return " · ".join(parts)
|
|
232
|
+
lines = [ln.strip() for ln in screen.splitlines() if ln.strip()]
|
|
233
|
+
for line in reversed(lines):
|
|
234
|
+
if _CLAUDE_MODEL.search(line):
|
|
235
|
+
return re.sub(r"\s{2,}", " ", line)[:80]
|
|
236
|
+
return lines[-1][:80] if lines else ""
|