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 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
@@ -0,0 +1,8 @@
1
+ """aipager — Telegram remote control for Claude Code sessions."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("aipager")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0+unknown"
aipager/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m aipager` — delegates to the CLI."""
2
+
3
+ from aipager.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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")
@@ -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