aipager 0.2.0__py3-none-any.whl
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.
- aipager/OBSERVERS.md +44 -0
- aipager/__init__.py +8 -0
- aipager/__main__.py +6 -0
- aipager/_dtach_redraw.py +95 -0
- aipager/cli.py +114 -0
- aipager/config.py +131 -0
- aipager/dtach_inject.py +211 -0
- aipager/dtach_launcher.py +134 -0
- aipager/hook_receiver.py +550 -0
- aipager/md_to_tg.py +73 -0
- aipager/notify_hook.py +72 -0
- aipager/observer.py +74 -0
- aipager/session_monitor.py +88 -0
- aipager/setup_wizard.py +212 -0
- aipager/state.py +296 -0
- aipager/statusline_notify.py +71 -0
- aipager/telegram_bot.py +2242 -0
- aipager/transcript.py +122 -0
- aipager-0.2.0.dist-info/METADATA +116 -0
- aipager-0.2.0.dist-info/RECORD +23 -0
- aipager-0.2.0.dist-info/WHEEL +4 -0
- aipager-0.2.0.dist-info/entry_points.txt +5 -0
- aipager-0.2.0.dist-info/licenses/LICENSE +21 -0
aipager/OBSERVERS.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Observer Bots
|
|
2
|
+
|
|
3
|
+
Read-only Telegram bots that mirror notifications from the primary bot. They receive summaries, warnings, and errors — but can't control sessions.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. Create a bot via [@BotFather](https://t.me/BotFather)
|
|
8
|
+
2. Start a chat with the bot and send `/start`
|
|
9
|
+
3. Get your chat ID (send a message, then check `https://api.telegram.org/bot<TOKEN>/getUpdates`)
|
|
10
|
+
4. Add to `.env`:
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
OBSERVER_BOTS=<bot_token>:<chat_id>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Multiple observers (comma-separated):
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
OBSERVER_BOTS=111:AAA_first:12345,222:BBB_second:67890
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The format is `token:chat_id` — parsing uses the **last** colon as delimiter (bot tokens contain an internal colon).
|
|
23
|
+
|
|
24
|
+
5. Restart the daemon
|
|
25
|
+
|
|
26
|
+
## What observers receive
|
|
27
|
+
|
|
28
|
+
| Event | Example |
|
|
29
|
+
|-------|---------|
|
|
30
|
+
| Idle summary | "Finished" + response text (+ .txt file for long responses) |
|
|
31
|
+
| API error | "Anthropic servers overloaded" (no retry button) |
|
|
32
|
+
| Context warning | "Context at 82% — auto-compact soon" |
|
|
33
|
+
| Compacting | "Compacting" |
|
|
34
|
+
| Compact done | "Compacted: 82% → 4%" |
|
|
35
|
+
|
|
36
|
+
## What observers DON'T receive
|
|
37
|
+
|
|
38
|
+
- Busy animations / spinner
|
|
39
|
+
- Tool call updates
|
|
40
|
+
- Permission prompts (Allow/Deny)
|
|
41
|
+
- AskUserQuestion dialogs
|
|
42
|
+
- Any inline keyboards or buttons
|
|
43
|
+
|
|
44
|
+
Observers are completely stateless — fire-and-forget sends. A failing observer never affects the primary bot.
|
aipager/__init__.py
ADDED
aipager/__main__.py
ADDED
aipager/_dtach_redraw.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Force a TUI redraw in a dtach session by bouncing the PTY window size.
|
|
2
|
+
|
|
3
|
+
dtach has no screen buffer — reattaching shows a blank screen. TUI apps
|
|
4
|
+
(Ink/React) only redraw on genuine dimension changes due to three
|
|
5
|
+
independent same-size guards (Linux kernel, Node.js, Ink).
|
|
6
|
+
|
|
7
|
+
`redraw(name)` changes the PTY size to (rows-1, cols), waits 50ms, then
|
|
8
|
+
restores (rows, cols) — forcing two genuine SIGWINCH signals and a full
|
|
9
|
+
redraw.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fcntl
|
|
15
|
+
import os
|
|
16
|
+
import struct
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import termios
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_pty(session_name: str) -> str | None:
|
|
24
|
+
"""Find the PTY slave device for a claude-dtach session's child process."""
|
|
25
|
+
try:
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["pgrep", "-f", f"dtach -n.*/claude-dtach-{session_name}\\.sock"],
|
|
28
|
+
capture_output=True, text=True, timeout=5,
|
|
29
|
+
)
|
|
30
|
+
dtach_pid = result.stdout.strip().split("\n")[0]
|
|
31
|
+
if not dtach_pid:
|
|
32
|
+
return None
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
["pgrep", "-P", dtach_pid],
|
|
39
|
+
capture_output=True, text=True, timeout=5,
|
|
40
|
+
)
|
|
41
|
+
child_pid = result.stdout.strip().split("\n")[0]
|
|
42
|
+
if not child_pid:
|
|
43
|
+
return None
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
pty = os.readlink(f"/proc/{child_pid}/fd/1")
|
|
49
|
+
if pty.startswith("/dev/pts/"):
|
|
50
|
+
return pty
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bounce_size(pty_path: str) -> bool:
|
|
57
|
+
"""Bounce PTY dimensions: (rows-1) then restore. Triggers two SIGWINCHs."""
|
|
58
|
+
try:
|
|
59
|
+
fd = os.open(pty_path, os.O_RDWR | os.O_NOCTTY)
|
|
60
|
+
except OSError:
|
|
61
|
+
return False
|
|
62
|
+
try:
|
|
63
|
+
buf = fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\x00" * 8)
|
|
64
|
+
rows, cols, xpix, ypix = struct.unpack("HHHH", buf)
|
|
65
|
+
if rows <= 1:
|
|
66
|
+
return False
|
|
67
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ,
|
|
68
|
+
struct.pack("HHHH", rows - 1, cols, xpix, ypix))
|
|
69
|
+
time.sleep(0.05)
|
|
70
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ,
|
|
71
|
+
struct.pack("HHHH", rows, cols, xpix, ypix))
|
|
72
|
+
return True
|
|
73
|
+
except Exception:
|
|
74
|
+
return False
|
|
75
|
+
finally:
|
|
76
|
+
os.close(fd)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def redraw(session_name: str) -> bool:
|
|
80
|
+
"""Bounce PTY size for the given session — returns True on success."""
|
|
81
|
+
pty = find_pty(session_name)
|
|
82
|
+
if not pty:
|
|
83
|
+
return False
|
|
84
|
+
return bounce_size(pty)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main() -> int:
|
|
88
|
+
if len(sys.argv) != 2:
|
|
89
|
+
print(f"Usage: {sys.argv[0]} <session-name>", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
return 0 if redraw(sys.argv[1]) else 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
sys.exit(main())
|
aipager/cli.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Top-level CLI for aipager.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
start run the daemon in the foreground
|
|
5
|
+
config interactive setup wizard (configures Telegram + Claude Code)
|
|
6
|
+
version print version
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from aipager import __version__
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("aipager")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def _run_daemon() -> None:
|
|
23
|
+
"""Boot the daemon and run until SIGINT/SIGTERM."""
|
|
24
|
+
from aipager.config import BOT_TOKEN, OBSERVER_BOTS
|
|
25
|
+
from aipager.hook_receiver import HookReceiver
|
|
26
|
+
from aipager.observer import ObserverBroadcaster
|
|
27
|
+
from aipager.session_monitor import SessionMonitor
|
|
28
|
+
from aipager.state import SessionRegistry
|
|
29
|
+
from aipager.telegram_bot import TelegramBot
|
|
30
|
+
|
|
31
|
+
if not BOT_TOKEN:
|
|
32
|
+
log.error("CLAUDE_TG_BOT_TOKEN not set — run `aipager config` first")
|
|
33
|
+
sys.exit(1)
|
|
34
|
+
|
|
35
|
+
registry = SessionRegistry()
|
|
36
|
+
registry.load()
|
|
37
|
+
bot = TelegramBot(registry)
|
|
38
|
+
hook_receiver = HookReceiver(registry, bot.notify)
|
|
39
|
+
session_monitor = SessionMonitor(registry, bot.notify)
|
|
40
|
+
|
|
41
|
+
await bot.start()
|
|
42
|
+
observers = None
|
|
43
|
+
if OBSERVER_BOTS:
|
|
44
|
+
observers = ObserverBroadcaster(OBSERVER_BOTS)
|
|
45
|
+
await observers.start()
|
|
46
|
+
bot.observers = observers
|
|
47
|
+
await hook_receiver.start()
|
|
48
|
+
await bot.recover_sessions()
|
|
49
|
+
session_monitor.on_sessions_changed = bot._update_bot_commands
|
|
50
|
+
await session_monitor.start()
|
|
51
|
+
|
|
52
|
+
log.info("AIPager running — all components started")
|
|
53
|
+
|
|
54
|
+
stop = asyncio.Event()
|
|
55
|
+
loop = asyncio.get_running_loop()
|
|
56
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
57
|
+
loop.add_signal_handler(sig, stop.set)
|
|
58
|
+
|
|
59
|
+
await stop.wait()
|
|
60
|
+
|
|
61
|
+
log.info("Shutting down...")
|
|
62
|
+
registry.save()
|
|
63
|
+
session_monitor.stop()
|
|
64
|
+
hook_receiver.stop()
|
|
65
|
+
if observers:
|
|
66
|
+
await observers.stop()
|
|
67
|
+
await bot.stop()
|
|
68
|
+
log.info("Goodbye")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _cmd_start(args: argparse.Namespace) -> int:
|
|
72
|
+
logging.basicConfig(
|
|
73
|
+
level=logging.INFO,
|
|
74
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
75
|
+
datefmt="%H:%M:%S",
|
|
76
|
+
)
|
|
77
|
+
asyncio.run(_run_daemon())
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _cmd_config(args: argparse.Namespace) -> int:
|
|
82
|
+
from aipager.setup_wizard import run
|
|
83
|
+
return run()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _cmd_version(args: argparse.Namespace) -> int:
|
|
87
|
+
print(__version__)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> None:
|
|
92
|
+
parser = argparse.ArgumentParser(
|
|
93
|
+
prog="aipager",
|
|
94
|
+
description="Telegram remote-control daemon for Claude Code sessions",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument("--version", action="version",
|
|
97
|
+
version=f"aipager {__version__}")
|
|
98
|
+
sub = parser.add_subparsers(dest="cmd")
|
|
99
|
+
sub.add_parser("start", help="run the daemon in the foreground"
|
|
100
|
+
).set_defaults(fn=_cmd_start)
|
|
101
|
+
sub.add_parser("config", help="interactive setup wizard"
|
|
102
|
+
).set_defaults(fn=_cmd_config)
|
|
103
|
+
sub.add_parser("version", help="print version"
|
|
104
|
+
).set_defaults(fn=_cmd_version)
|
|
105
|
+
|
|
106
|
+
args = parser.parse_args()
|
|
107
|
+
if not args.cmd:
|
|
108
|
+
parser.print_help()
|
|
109
|
+
sys.exit(0)
|
|
110
|
+
sys.exit(args.fn(args))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
main()
|
aipager/config.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Configuration for aipager — loads env from XDG path or project root."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_XDG_CONFIG = Path.home() / ".config" / "aipager" / "config.env"
|
|
7
|
+
_PROJECT_DOTENV = Path(__file__).parent.parent / ".env"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _load_env_file() -> None:
|
|
11
|
+
"""Load environment variables.
|
|
12
|
+
|
|
13
|
+
Source priority (first existing file wins):
|
|
14
|
+
1. ~/.config/aipager/config.env (XDG, written by `aipager config`)
|
|
15
|
+
2. <project-root>/.env (legacy / development checkouts)
|
|
16
|
+
"""
|
|
17
|
+
for candidate in (_XDG_CONFIG, _PROJECT_DOTENV):
|
|
18
|
+
if candidate.exists():
|
|
19
|
+
for line in candidate.read_text().splitlines():
|
|
20
|
+
line = line.strip()
|
|
21
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
22
|
+
continue
|
|
23
|
+
key, _, value = line.partition("=")
|
|
24
|
+
key = key.strip()
|
|
25
|
+
value = value.strip().strip("\"'")
|
|
26
|
+
if key and key not in os.environ:
|
|
27
|
+
os.environ[key] = value
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_load_env_file()
|
|
32
|
+
|
|
33
|
+
BOT_TOKEN: str = os.environ.get("CLAUDE_TG_BOT_TOKEN", "")
|
|
34
|
+
CHAT_ID: str = os.environ.get("CLAUDE_TG_CHAT_ID", "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_observer_bots(raw: str) -> list[tuple[str, str]]:
|
|
38
|
+
"""Parse 'token1:chatid1,token2:chatid2' into [(token, chatid), ...].
|
|
39
|
+
|
|
40
|
+
Uses rsplit(":", 1) to split at the LAST colon, since bot tokens
|
|
41
|
+
contain an internal colon (format NNNNN:XXXXXX).
|
|
42
|
+
"""
|
|
43
|
+
if not raw.strip():
|
|
44
|
+
return []
|
|
45
|
+
result = []
|
|
46
|
+
for entry in raw.split(","):
|
|
47
|
+
entry = entry.strip()
|
|
48
|
+
if ":" not in entry:
|
|
49
|
+
continue
|
|
50
|
+
parts = entry.rsplit(":", 1)
|
|
51
|
+
if len(parts) != 2:
|
|
52
|
+
continue
|
|
53
|
+
token, chat_id = parts[0].strip(), parts[1].strip()
|
|
54
|
+
if token and chat_id:
|
|
55
|
+
result.append((token, chat_id))
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
OBSERVER_BOTS: list[tuple[str, str]] = _parse_observer_bots(
|
|
60
|
+
os.environ.get("OBSERVER_BOTS", "")
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Unix datagram socket for hook → daemon communication
|
|
64
|
+
SOCKET_PATH: str = "/tmp/aipager.sock"
|
|
65
|
+
|
|
66
|
+
# Pane monitor interval (seconds)
|
|
67
|
+
PANE_POLL_INTERVAL: float = 2.0
|
|
68
|
+
|
|
69
|
+
# Use transcript JSONL for rich markdown→HTML summaries in Telegram notifications.
|
|
70
|
+
# When False, uses pane-scraped plain text in expandable blockquotes (old behavior).
|
|
71
|
+
RICH_SUMMARIES: bool = os.environ.get("CLAUDE_RICH_SUMMARIES", "1") not in ("0", "false", "no")
|
|
72
|
+
|
|
73
|
+
# Session state persistence (survives daemon restarts)
|
|
74
|
+
SESSION_STATE_FILE = Path.home() / ".claude" / "aipager-sessions.json"
|
|
75
|
+
|
|
76
|
+
# Minimum seconds between busy-message edits (rate-limit for Telegram API)
|
|
77
|
+
BUSY_EDIT_INTERVAL: float = 3.0
|
|
78
|
+
|
|
79
|
+
# Stale busy session threshold (seconds) — alert if BUSY with no hooks for this long
|
|
80
|
+
STALE_BUSY_TIMEOUT: float = float(os.environ.get("STALE_BUSY_TIMEOUT", "1200"))
|
|
81
|
+
|
|
82
|
+
# Spinner verbs for animated busy messages (curated from Claude Code's terminal spinner)
|
|
83
|
+
SPINNER_VERBS: list[str] = [
|
|
84
|
+
"Thinking", "Reasoning", "Pondering", "Considering", "Analyzing",
|
|
85
|
+
"Processing", "Synthesizing", "Deliberating", "Evaluating", "Mulling",
|
|
86
|
+
"Contemplating", "Inferring", "Cogitating", "Puzzling", "Calculating",
|
|
87
|
+
"Deciphering", "Formulating", "Examining", "Investigating", "Brewing",
|
|
88
|
+
"Cooking", "Crafting", "Forging", "Conjuring", "Noodling",
|
|
89
|
+
"Percolating", "Simmering", "Ruminating", "Musing", "Tinkering",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
# Quick template buttons for Telegram persistent keyboard
|
|
93
|
+
TEMPLATES_BUTTON = "Templates"
|
|
94
|
+
BACK_BUTTON = "\u00ab Back"
|
|
95
|
+
QUICK_TEMPLATES: list[tuple[str, str]] = [
|
|
96
|
+
("Continue", "Continue"),
|
|
97
|
+
("Run tests", "Run the tests"),
|
|
98
|
+
("Commit", "Commit the changes with a descriptive message"),
|
|
99
|
+
("LGTM ship it", "LGTM, ship it"),
|
|
100
|
+
("Show diff", "Show me the git diff of all changes"),
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
# Claude Code slash commands — instant commands (no BUSY transition)
|
|
104
|
+
# Only commands that CHANGE BEHAVIOR belong here. Commands that just
|
|
105
|
+
# display info in the terminal (cost, context, stats, doctor) are useless
|
|
106
|
+
# remotely since the user can't see the terminal output in Telegram.
|
|
107
|
+
COMMANDS_BUTTON = "Commands"
|
|
108
|
+
QUICK_COMMANDS: list[tuple[str, str]] = [
|
|
109
|
+
("Compact", "/compact"),
|
|
110
|
+
("Clear", "/clear"),
|
|
111
|
+
("Plan mode", "/plan"),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
# Model submenu — accessible from Commands → Model
|
|
115
|
+
MODELS_BUTTON = "Model \u203a"
|
|
116
|
+
MODEL_CHOICES: list[tuple[str, str]] = [
|
|
117
|
+
("Sonnet", "/model sonnet"),
|
|
118
|
+
("Opus", "/model opus"),
|
|
119
|
+
("Haiku", "/model haiku"),
|
|
120
|
+
("OpusPlan", "/model opusplan"),
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
# Parent level for each keyboard level (for context-aware Back button)
|
|
124
|
+
KEYBOARD_PARENTS: dict[str, str] = {
|
|
125
|
+
"templates": "main",
|
|
126
|
+
"commands": "main",
|
|
127
|
+
"models": "commands",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Directory for files downloaded from Telegram (photos, documents)
|
|
131
|
+
FILE_DOWNLOAD_DIR = Path("/tmp/aipager-files")
|
aipager/dtach_inject.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Async dtach wrappers — inject keystrokes and check session liveness.
|
|
2
|
+
|
|
3
|
+
Uses `dtach -p <socket>` to send raw bytes to the session's PTY via stdin.
|
|
4
|
+
|
|
5
|
+
Socket naming: session "claude-dev" → /tmp/claude-dtach-dev.sock
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
SOCK_PREFIX = "/tmp/claude-dtach-"
|
|
18
|
+
|
|
19
|
+
# Logical key names → ANSI escape sequences
|
|
20
|
+
KEYS = {
|
|
21
|
+
"Enter": "\r",
|
|
22
|
+
"Down": "\x1b[B",
|
|
23
|
+
"Up": "\x1b[A",
|
|
24
|
+
"Right": "\x1b[C",
|
|
25
|
+
"Left": "\x1b[D",
|
|
26
|
+
"Tab": "\t",
|
|
27
|
+
"Escape": "\x1b",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _run(args: list[str], stdin: bytes = b"",
|
|
32
|
+
timeout: float = 5) -> tuple[bool, str]:
|
|
33
|
+
"""Run subprocess, optionally piping stdin, return (success, stdout)."""
|
|
34
|
+
try:
|
|
35
|
+
proc = await asyncio.create_subprocess_exec(
|
|
36
|
+
*args,
|
|
37
|
+
stdin=asyncio.subprocess.PIPE if stdin else asyncio.subprocess.DEVNULL,
|
|
38
|
+
stdout=asyncio.subprocess.PIPE,
|
|
39
|
+
stderr=asyncio.subprocess.PIPE,
|
|
40
|
+
)
|
|
41
|
+
stdout, stderr = await asyncio.wait_for(
|
|
42
|
+
proc.communicate(stdin or None), timeout=timeout,
|
|
43
|
+
)
|
|
44
|
+
if proc.returncode == 0:
|
|
45
|
+
return True, stdout.decode()
|
|
46
|
+
log.error("dtach cmd failed: %s — %s", args, stderr.decode().strip())
|
|
47
|
+
return False, ""
|
|
48
|
+
except asyncio.TimeoutError:
|
|
49
|
+
log.error("dtach cmd timed out: %s", args)
|
|
50
|
+
return False, ""
|
|
51
|
+
except FileNotFoundError:
|
|
52
|
+
log.error("dtach not found")
|
|
53
|
+
return False, ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sock_path(session: str) -> str:
|
|
57
|
+
"""Convert session name 'claude-dev' to socket path '/tmp/claude-dtach-dev.sock'."""
|
|
58
|
+
name = session.removeprefix("claude-")
|
|
59
|
+
return f"{SOCK_PREFIX}{name}.sock"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def send_keys(session: str, keys: str) -> bool:
|
|
63
|
+
"""Send a key sequence to the dtach session.
|
|
64
|
+
|
|
65
|
+
`keys` can be a logical name ("Enter", "Down") or raw text.
|
|
66
|
+
"""
|
|
67
|
+
seq = KEYS.get(keys, keys)
|
|
68
|
+
sock = _sock_path(session)
|
|
69
|
+
ok, _ = await _run(["dtach", "-p", sock], stdin=seq.encode())
|
|
70
|
+
if ok:
|
|
71
|
+
log.info("Sent keys %r → %s", keys, session)
|
|
72
|
+
return ok
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def send_text_and_enter(session: str, text: str) -> bool:
|
|
76
|
+
"""Send literal text followed by Enter.
|
|
77
|
+
|
|
78
|
+
Text and Enter must be separate dtach -p calls — Claude Code's TUI
|
|
79
|
+
treats a single chunk (text + CR) as all-text input. A separate CR
|
|
80
|
+
write is needed to trigger the submit keypress event.
|
|
81
|
+
"""
|
|
82
|
+
sock = _sock_path(session)
|
|
83
|
+
ok, _ = await _run(["dtach", "-p", sock], stdin=text.encode())
|
|
84
|
+
if not ok:
|
|
85
|
+
return False
|
|
86
|
+
# Claude Code's Ink TUI needs time to process text input before
|
|
87
|
+
# Enter is recognized as "submit". Too short → \r is swallowed.
|
|
88
|
+
# Scale with text length: longer text = more rendering time needed.
|
|
89
|
+
delay = max(0.15, min(0.5, len(text) * 0.003))
|
|
90
|
+
await asyncio.sleep(delay)
|
|
91
|
+
ok, _ = await _run(["dtach", "-p", sock], stdin=b"\r")
|
|
92
|
+
if ok:
|
|
93
|
+
log.info("Sent text %r + Enter → %s", text[:50], session)
|
|
94
|
+
return ok
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def kill_session(session: str) -> bool:
|
|
98
|
+
"""Kill a dtach session by finding its host PID and terminating it."""
|
|
99
|
+
sock = _sock_path(session)
|
|
100
|
+
sock_path = Path(sock)
|
|
101
|
+
if not sock_path.is_socket():
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
# Find the dtach host process (dtach -n <sock> ...)
|
|
105
|
+
try:
|
|
106
|
+
proc = await asyncio.create_subprocess_exec(
|
|
107
|
+
"fuser", sock,
|
|
108
|
+
stdout=asyncio.subprocess.PIPE,
|
|
109
|
+
stderr=asyncio.subprocess.PIPE,
|
|
110
|
+
)
|
|
111
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
|
112
|
+
pids = stdout.decode().split()
|
|
113
|
+
for pid_str in pids:
|
|
114
|
+
pid_str = pid_str.strip()
|
|
115
|
+
if pid_str.isdigit():
|
|
116
|
+
import os
|
|
117
|
+
import signal
|
|
118
|
+
os.kill(int(pid_str), signal.SIGTERM)
|
|
119
|
+
log.info("Killed dtach PID %s for %s", pid_str, session)
|
|
120
|
+
except Exception:
|
|
121
|
+
log.warning("Failed to find/kill dtach PID for %s", session, exc_info=True)
|
|
122
|
+
|
|
123
|
+
# Remove socket as fallback (dtach should clean up, but ensure it)
|
|
124
|
+
try:
|
|
125
|
+
sock_path.unlink(missing_ok=True)
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
async def is_alive(session: str) -> bool:
|
|
132
|
+
"""Check if a dtach session socket exists and is connectable."""
|
|
133
|
+
sock = _sock_path(session)
|
|
134
|
+
return Path(sock).is_socket()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_VALID_NAME = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$")
|
|
138
|
+
_RESERVED = {"status", "stop", "kill", "new", "help", "start", "settings"}
|
|
139
|
+
_PROJECT_DIR = os.environ.get("AIPAGER_WORK_DIR", os.getcwd())
|
|
140
|
+
_CLAUDE_BIN = shutil.which("claude") or "claude"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def launch_session(name: str, skip_perms: bool = True) -> tuple[bool, str]:
|
|
144
|
+
"""Launch a new Claude Code session inside dtach.
|
|
145
|
+
|
|
146
|
+
Returns (success, error_message). The session_monitor will auto-discover
|
|
147
|
+
the new session within 2 seconds.
|
|
148
|
+
"""
|
|
149
|
+
if not name or not _VALID_NAME.match(name):
|
|
150
|
+
return False, "Invalid name (use letters, numbers, hyphens)"
|
|
151
|
+
if name.lower() in _RESERVED:
|
|
152
|
+
return False, f"'{name}' is a reserved command name"
|
|
153
|
+
if len(name) > 30:
|
|
154
|
+
return False, "Name too long (max 30 chars)"
|
|
155
|
+
|
|
156
|
+
sock = f"{SOCK_PREFIX}{name}.sock"
|
|
157
|
+
if Path(sock).is_socket():
|
|
158
|
+
return False, f"Session '{name}' already exists"
|
|
159
|
+
|
|
160
|
+
# Build the bash -c command — wraps claude with env vars and prompt
|
|
161
|
+
perms = "--dangerously-skip-permissions" if skip_perms else ""
|
|
162
|
+
sys_prompt = (f'Your session name is "{name}". '
|
|
163
|
+
f'When users address you by this name, respond naturally '
|
|
164
|
+
f'-- it is your name in this session.')
|
|
165
|
+
# `unset CLAUDECODE`: Claude Code sets this env var when running, and
|
|
166
|
+
# the binary refuses to launch a second time if it sees it ("already
|
|
167
|
+
# inside a Claude Code session"). Strip it so /new sessions can launch
|
|
168
|
+
# cleanly from inside a parent Claude.
|
|
169
|
+
bash_cmd = (
|
|
170
|
+
f"unset CLAUDECODE; "
|
|
171
|
+
f"export CLAUDE_DTACH_SESSION=claude-{name}; "
|
|
172
|
+
f"{_CLAUDE_BIN} {perms} "
|
|
173
|
+
f"--append-system-prompt '{sys_prompt}'"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
proc = await asyncio.create_subprocess_exec(
|
|
178
|
+
"dtach", "-n", sock, "-Ez", "bash", "-c", bash_cmd,
|
|
179
|
+
cwd=_PROJECT_DIR,
|
|
180
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
181
|
+
stderr=asyncio.subprocess.PIPE,
|
|
182
|
+
)
|
|
183
|
+
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
|
|
184
|
+
if proc.returncode != 0:
|
|
185
|
+
return False, f"dtach failed: {stderr.decode().strip()}"
|
|
186
|
+
except FileNotFoundError:
|
|
187
|
+
return False, "dtach not installed"
|
|
188
|
+
except asyncio.TimeoutError:
|
|
189
|
+
return False, "dtach launch timed out"
|
|
190
|
+
|
|
191
|
+
# Wait for socket to appear (dtach creates it asynchronously)
|
|
192
|
+
for _ in range(10):
|
|
193
|
+
await asyncio.sleep(0.3)
|
|
194
|
+
if Path(sock).is_socket():
|
|
195
|
+
log.info("Launched session claude-%s (socket: %s)", name, sock)
|
|
196
|
+
return True, ""
|
|
197
|
+
return False, "Socket never appeared after launch"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async def list_sessions() -> list[str]:
|
|
201
|
+
"""Return names of all active claude-dtach sessions.
|
|
202
|
+
|
|
203
|
+
Scans /tmp for claude-dtach-*.sock files that are Unix sockets.
|
|
204
|
+
"""
|
|
205
|
+
results = []
|
|
206
|
+
for sock_file in Path("/tmp").glob("claude-dtach-*.sock"):
|
|
207
|
+
if not sock_file.is_socket():
|
|
208
|
+
continue
|
|
209
|
+
name = "claude-" + sock_file.stem.removeprefix("claude-dtach-")
|
|
210
|
+
results.append(name)
|
|
211
|
+
return results
|