tetherly 0.1.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.
tetherly-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 changhyeon363
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: tetherly
3
+ Version: 0.1.0
4
+ Summary: Discord to tmux session bridge for agent-driven workflows.
5
+ Author-email: changhyeon363 <changhyeon363@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: discord.py<3,>=2.5
11
+ Provides-Extra: docs
12
+ Requires-Dist: zensical<1,>=0.0.37; extra == "docs"
13
+ Dynamic: license-file
14
+
15
+ <p align="center">
16
+ <img src="docs/assets/images/tetherly-icon.png" alt="tetherly icon" width="96" height="96">
17
+ </p>
18
+
19
+ # tetherly
20
+
21
+ Discord channel ↔ tmux session bridge.
22
+
23
+ > 📖 **Documentation is in [docs/](docs/) — split into [user docs](docs/user/) (setup, commands, troubleshooting) and [contributing docs](docs/contributing/) (internals).** This README is a quick start.
24
+
25
+ ## Features
26
+
27
+ - `/bind session:<name>`: bind the current Discord channel to a tmux session
28
+ - `/config auto_send:<true|false>`: enable or disable plain-text auto-send for the current bound channel
29
+ - `/send text:<message>`: send text plus Enter into the bound tmux session
30
+ - `/key key:<Enter|Escape|Ctrl-C|Ctrl-D|Tab|Up|Down|Left|Right>`: send a special key into the bound tmux session
31
+ - `/tail lines:<n>`: fetch recent tmux output
32
+ - `/status`: inspect the current binding and tmux session status
33
+ - `tetherly discord-send --message <text>`: let an agent inside a bound tmux session send a reply back to Discord
34
+ - `tetherly codex-stop` / `tetherly codex-permission-request`: Codex hook handlers that forward messages to the bound Discord channel
35
+
36
+ ## Requirements
37
+
38
+ - Python 3.11+
39
+ - `tmux` installed
40
+ - A Discord bot token (Message Content Intent enabled if you want plain-text auto-send)
41
+
42
+ ## Setup
43
+
44
+ Install once on your machine:
45
+
46
+ ```bash
47
+ pipx install tetherly
48
+ tetherly init
49
+ ```
50
+
51
+ `tetherly init` is interactive. It writes `~/.tetherly/.env` and asks where to install Codex hooks:
52
+
53
+ - **Global** — writes `~/.codex/hooks.json` once. Hooks fire in every project automatically; nothing per-project.
54
+ - **Project** — skip global hooks and run `tetherly install-hooks` inside each project where you want them.
55
+ - **Skip** — don't touch Codex hooks.
56
+
57
+ Then start the bot:
58
+
59
+ ```bash
60
+ tetherly
61
+ ```
62
+
63
+ That's it. State lives at `~/.tetherly/state.json` so a single bot can serve every project.
64
+
65
+ ### Per-project usage
66
+
67
+ For each project you want to drive from Discord:
68
+
69
+ ```bash
70
+ tmux new -s <session-name>
71
+ # inside the bound channel on Discord:
72
+ # /bind session:<session-name>
73
+ # /config auto_send:true
74
+ ```
75
+
76
+ If you chose **Project** mode during init, also run once per project:
77
+
78
+ ```bash
79
+ cd <project>
80
+ tetherly install-hooks
81
+ ```
82
+
83
+ `install-hooks` accepts `--global` to (re)install user-level hooks instead.
84
+
85
+ ### Sending from inside a session
86
+
87
+ ```bash
88
+ tetherly discord-send --message "작업 끝났습니다"
89
+ cat result.txt | tetherly discord-send --stdin
90
+ tetherly discord-send --session t1 --message "..." # explicit session
91
+ ```
92
+
93
+ ## Configuration
94
+
95
+ `tetherly init` writes everything you need. Advanced overrides live in `~/.tetherly/.env` or shell env:
96
+
97
+ | Variable | Default | Notes |
98
+ | --- | --- | --- |
99
+ | `DISCORD_BOT_TOKEN` | (required) | Bot token |
100
+ | `TETHERLY_ALLOWED_USER_IDS` | (required) | Comma-separated user IDs |
101
+ | `TETHERLY_ALLOWED_GUILD_IDS` | — | Restrict commands to these guilds |
102
+ | `TETHERLY_ALLOWED_ROLE_IDS` | — | Allow members holding any of these roles |
103
+ | `TETHERLY_TEST_GUILD_ID` | — | Dev guild for instant slash-command sync |
104
+ | `TETHERLY_STATE_PATH` | `~/.tetherly/state.json` | Where bindings are persisted |
105
+ | `TETHERLY_DEFAULT_TAIL_LINES` | `40` | Default `/tail` line count |
106
+ | `TETHERLY_MAX_TAIL_LINES` | `200` | Cap for `/tail` |
107
+ | `TETHERLY_LOG_LEVEL` | `INFO` | Logger verbosity |
108
+
109
+ A `.env` in the current working directory still overrides `~/.tetherly/.env`.
110
+
111
+ ## Codex hooks
112
+
113
+ Both hooks only fire when the active tmux session has `TETHERLY_NOTIFY_ON_FINISH=1` — `/bind` sets that flag automatically, so projects without a binding stay silent even when global hooks are installed.
114
+
115
+ - `Stop` → `tetherly codex-stop` forwards `last_assistant_message` to the bound channel.
116
+ - `PermissionRequest` → `tetherly codex-permission-request` forwards the tool/command/reason. It does not return an `allow`/`deny` decision, so Codex's normal approval prompt still appears.
@@ -0,0 +1,102 @@
1
+ <p align="center">
2
+ <img src="docs/assets/images/tetherly-icon.png" alt="tetherly icon" width="96" height="96">
3
+ </p>
4
+
5
+ # tetherly
6
+
7
+ Discord channel ↔ tmux session bridge.
8
+
9
+ > 📖 **Documentation is in [docs/](docs/) — split into [user docs](docs/user/) (setup, commands, troubleshooting) and [contributing docs](docs/contributing/) (internals).** This README is a quick start.
10
+
11
+ ## Features
12
+
13
+ - `/bind session:<name>`: bind the current Discord channel to a tmux session
14
+ - `/config auto_send:<true|false>`: enable or disable plain-text auto-send for the current bound channel
15
+ - `/send text:<message>`: send text plus Enter into the bound tmux session
16
+ - `/key key:<Enter|Escape|Ctrl-C|Ctrl-D|Tab|Up|Down|Left|Right>`: send a special key into the bound tmux session
17
+ - `/tail lines:<n>`: fetch recent tmux output
18
+ - `/status`: inspect the current binding and tmux session status
19
+ - `tetherly discord-send --message <text>`: let an agent inside a bound tmux session send a reply back to Discord
20
+ - `tetherly codex-stop` / `tetherly codex-permission-request`: Codex hook handlers that forward messages to the bound Discord channel
21
+
22
+ ## Requirements
23
+
24
+ - Python 3.11+
25
+ - `tmux` installed
26
+ - A Discord bot token (Message Content Intent enabled if you want plain-text auto-send)
27
+
28
+ ## Setup
29
+
30
+ Install once on your machine:
31
+
32
+ ```bash
33
+ pipx install tetherly
34
+ tetherly init
35
+ ```
36
+
37
+ `tetherly init` is interactive. It writes `~/.tetherly/.env` and asks where to install Codex hooks:
38
+
39
+ - **Global** — writes `~/.codex/hooks.json` once. Hooks fire in every project automatically; nothing per-project.
40
+ - **Project** — skip global hooks and run `tetherly install-hooks` inside each project where you want them.
41
+ - **Skip** — don't touch Codex hooks.
42
+
43
+ Then start the bot:
44
+
45
+ ```bash
46
+ tetherly
47
+ ```
48
+
49
+ That's it. State lives at `~/.tetherly/state.json` so a single bot can serve every project.
50
+
51
+ ### Per-project usage
52
+
53
+ For each project you want to drive from Discord:
54
+
55
+ ```bash
56
+ tmux new -s <session-name>
57
+ # inside the bound channel on Discord:
58
+ # /bind session:<session-name>
59
+ # /config auto_send:true
60
+ ```
61
+
62
+ If you chose **Project** mode during init, also run once per project:
63
+
64
+ ```bash
65
+ cd <project>
66
+ tetherly install-hooks
67
+ ```
68
+
69
+ `install-hooks` accepts `--global` to (re)install user-level hooks instead.
70
+
71
+ ### Sending from inside a session
72
+
73
+ ```bash
74
+ tetherly discord-send --message "작업 끝났습니다"
75
+ cat result.txt | tetherly discord-send --stdin
76
+ tetherly discord-send --session t1 --message "..." # explicit session
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ `tetherly init` writes everything you need. Advanced overrides live in `~/.tetherly/.env` or shell env:
82
+
83
+ | Variable | Default | Notes |
84
+ | --- | --- | --- |
85
+ | `DISCORD_BOT_TOKEN` | (required) | Bot token |
86
+ | `TETHERLY_ALLOWED_USER_IDS` | (required) | Comma-separated user IDs |
87
+ | `TETHERLY_ALLOWED_GUILD_IDS` | — | Restrict commands to these guilds |
88
+ | `TETHERLY_ALLOWED_ROLE_IDS` | — | Allow members holding any of these roles |
89
+ | `TETHERLY_TEST_GUILD_ID` | — | Dev guild for instant slash-command sync |
90
+ | `TETHERLY_STATE_PATH` | `~/.tetherly/state.json` | Where bindings are persisted |
91
+ | `TETHERLY_DEFAULT_TAIL_LINES` | `40` | Default `/tail` line count |
92
+ | `TETHERLY_MAX_TAIL_LINES` | `200` | Cap for `/tail` |
93
+ | `TETHERLY_LOG_LEVEL` | `INFO` | Logger verbosity |
94
+
95
+ A `.env` in the current working directory still overrides `~/.tetherly/.env`.
96
+
97
+ ## Codex hooks
98
+
99
+ Both hooks only fire when the active tmux session has `TETHERLY_NOTIFY_ON_FINISH=1` — `/bind` sets that flag automatically, so projects without a binding stay silent even when global hooks are installed.
100
+
101
+ - `Stop` → `tetherly codex-stop` forwards `last_assistant_message` to the bound channel.
102
+ - `PermissionRequest` → `tetherly codex-permission-request` forwards the tool/command/reason. It does not return an `allow`/`deny` decision, so Codex's normal approval prompt still appears.
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tetherly"
7
+ version = "0.1.0"
8
+ description = "Discord to tmux session bridge for agent-driven workflows."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "changhyeon363", email = "changhyeon363@gmail.com"},
14
+ ]
15
+ dependencies = [
16
+ "discord.py>=2.5,<3",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ docs = [
21
+ "zensical>=0.0.37,<1",
22
+ ]
23
+
24
+ [project.scripts]
25
+ tetherly = "tetherly.main:main"
26
+
27
+ [tool.setuptools]
28
+ package-dir = {"" = "src"}
29
+
30
+ [tool.setuptools.packages.find]
31
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """tetherly package."""
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import discord
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AccessController:
10
+ allowed_guild_ids: set[int]
11
+ allowed_role_ids: set[int]
12
+ allowed_user_ids: set[int]
13
+
14
+ def is_allowed_user(self, guild_id: int | None, user: object) -> bool:
15
+ if self.allowed_guild_ids and guild_id not in self.allowed_guild_ids:
16
+ return False
17
+ user_id = getattr(user, "id", None)
18
+ if user_id in self.allowed_user_ids:
19
+ return True
20
+ if not self.allowed_role_ids:
21
+ return user_id in self.allowed_user_ids
22
+ if isinstance(user, discord.Member):
23
+ role_ids = {role.id for role in user.roles}
24
+ return bool(role_ids & self.allowed_role_ids)
25
+ return False
26
+
27
+ def is_allowed(self, interaction: discord.Interaction) -> bool:
28
+ return self.is_allowed_user(interaction.guild_id, interaction.user)
29
+
30
+ async def assert_allowed(self, interaction: discord.Interaction) -> bool:
31
+ if self.is_allowed(interaction):
32
+ return True
33
+ await interaction.response.send_message(
34
+ "You are not allowed to use this command.",
35
+ ephemeral=True,
36
+ )
37
+ return False
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ USER_CONFIG_DIR = Path.home() / ".tetherly"
10
+ USER_ENV_PATH = USER_CONFIG_DIR / ".env"
11
+ USER_STATE_PATH = USER_CONFIG_DIR / "state.json"
12
+
13
+
14
+ def _load_env_file(path: Path) -> None:
15
+ if not path.exists():
16
+ return
17
+ for raw_line in path.read_text().splitlines():
18
+ line = raw_line.strip()
19
+ if not line or line.startswith("#"):
20
+ continue
21
+ if line.startswith("export "):
22
+ line = line[len("export ") :]
23
+ if "=" not in line:
24
+ continue
25
+ key, value = line.split("=", 1)
26
+ key = key.strip()
27
+ value = value.strip().strip("'").strip('"')
28
+ os.environ.setdefault(key, value)
29
+
30
+
31
+ def load_dotenv(path: str = ".env") -> None:
32
+ _load_env_file(Path(path))
33
+ _load_env_file(USER_ENV_PATH)
34
+
35
+
36
+ def _parse_id_set(raw: str | None) -> set[int]:
37
+ if not raw:
38
+ return set()
39
+ values: set[int] = set()
40
+ for chunk in raw.split(","):
41
+ chunk = chunk.strip()
42
+ if not chunk:
43
+ continue
44
+ values.add(int(chunk))
45
+ return values
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Config:
50
+ discord_bot_token: str
51
+ state_path: Path
52
+ allowed_guild_ids: set[int]
53
+ allowed_role_ids: set[int]
54
+ allowed_user_ids: set[int]
55
+ test_guild_id: int | None = None
56
+ default_tail_lines: int = 40
57
+ max_tail_lines: int = 200
58
+ log_level: str = "INFO"
59
+ command_prefix: str = "/"
60
+
61
+ @classmethod
62
+ def from_env(cls) -> "Config":
63
+ token = os.environ["DISCORD_BOT_TOKEN"].strip()
64
+ state_path = Path(os.environ.get("TETHERLY_STATE_PATH", str(USER_STATE_PATH)))
65
+ return cls(
66
+ discord_bot_token=token,
67
+ state_path=state_path,
68
+ allowed_guild_ids=_parse_id_set(os.environ.get("TETHERLY_ALLOWED_GUILD_IDS")),
69
+ allowed_role_ids=_parse_id_set(os.environ.get("TETHERLY_ALLOWED_ROLE_IDS")),
70
+ allowed_user_ids=_parse_id_set(os.environ.get("TETHERLY_ALLOWED_USER_IDS")),
71
+ test_guild_id=int(os.environ["TETHERLY_TEST_GUILD_ID"])
72
+ if os.environ.get("TETHERLY_TEST_GUILD_ID")
73
+ else None,
74
+ default_tail_lines=int(os.environ.get("TETHERLY_DEFAULT_TAIL_LINES", "40")),
75
+ max_tail_lines=int(os.environ.get("TETHERLY_MAX_TAIL_LINES", "200")),
76
+ log_level=os.environ.get("TETHERLY_LOG_LEVEL", "INFO").upper(),
77
+ )
78
+
79
+ def configure_logging(self) -> None:
80
+ logging.basicConfig(
81
+ level=getattr(logging, self.log_level, logging.INFO),
82
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
83
+ )
@@ -0,0 +1,283 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import discord
6
+ from discord import app_commands
7
+
8
+ from tetherly.authz import AccessController
9
+ from tetherly.config import Config
10
+ from tetherly.session_registry import SessionRegistry, SessionRegistryError
11
+ from tetherly.tmux_service import TmuxError, TmuxService, normalize_session_name
12
+
13
+ LOGGER = logging.getLogger(__name__)
14
+ AUTO_SEND_MAX_LENGTH = 4000
15
+
16
+
17
+ def _render_code_block(text: str) -> str:
18
+ stripped = text.strip()
19
+ if not stripped:
20
+ return "```text\n<empty>\n```"
21
+ return f"```text\n{stripped[:1800]}\n```"
22
+
23
+
24
+ def _extract_auto_send_text(message: discord.Message) -> str | None:
25
+ if message.author.bot or message.webhook_id is not None:
26
+ return None
27
+ if message.guild is None or isinstance(message.channel, discord.Thread):
28
+ return None
29
+ if message.type is not discord.MessageType.default:
30
+ return None
31
+ if message.reference is not None or message.attachments:
32
+ return None
33
+ content = message.content.strip()
34
+ if not content or content.startswith("/") or len(content) > AUTO_SEND_MAX_LENGTH:
35
+ return None
36
+ return content
37
+
38
+
39
+ class TetherlyBot(discord.Client):
40
+ def __init__(
41
+ self,
42
+ *,
43
+ config: Config,
44
+ registry: SessionRegistry,
45
+ tmux_service: TmuxService,
46
+ access_controller: AccessController,
47
+ ) -> None:
48
+ intents = discord.Intents.default()
49
+ intents.message_content = True
50
+ super().__init__(intents=intents)
51
+ self.config = config
52
+ self.registry = registry
53
+ self.tmux_service = tmux_service
54
+ self.access_controller = access_controller
55
+ self.tree = app_commands.CommandTree(self)
56
+
57
+ async def setup_hook(self) -> None:
58
+ self._register_commands()
59
+ if self.config.test_guild_id is not None:
60
+ guild = discord.Object(id=self.config.test_guild_id)
61
+ self.tree.copy_global_to(guild=guild)
62
+ await self.tree.sync(guild=guild)
63
+ LOGGER.info("synced commands to guild %s", self.config.test_guild_id)
64
+ return
65
+ await self.tree.sync()
66
+
67
+ async def on_ready(self) -> None:
68
+ LOGGER.info("bot ready as %s", self.user)
69
+
70
+ async def on_message(self, message: discord.Message) -> None:
71
+ if message.guild is None:
72
+ return
73
+ binding = self.registry.get(message.channel.id)
74
+ if binding is None or not binding.auto_send:
75
+ return
76
+ if not self.access_controller.is_allowed_user(message.guild.id, message.author):
77
+ return
78
+ content = _extract_auto_send_text(message)
79
+ if content is None:
80
+ return
81
+ try:
82
+ self.tmux_service.send_text(binding.session_name, content, press_enter=True)
83
+ except TmuxError as exc:
84
+ LOGGER.warning(
85
+ "auto-send failed for channel %s session %s: %s",
86
+ binding.channel_id,
87
+ binding.session_name,
88
+ exc,
89
+ )
90
+ return
91
+ self.registry.touch(binding.channel_id)
92
+
93
+ def _register_commands(self) -> None:
94
+ @self.tree.command(name="bind", description="Bind this Discord channel to a tmux session.")
95
+ @app_commands.describe(session="tmux session name")
96
+ async def bind(interaction: discord.Interaction, session: str) -> None:
97
+ if not await self.access_controller.assert_allowed(interaction):
98
+ return
99
+ if interaction.guild_id is None or interaction.channel_id is None:
100
+ await interaction.response.send_message(
101
+ "This command can only be used inside a guild channel.",
102
+ ephemeral=True,
103
+ )
104
+ return
105
+ try:
106
+ session_name = normalize_session_name(session)
107
+ except ValueError as exc:
108
+ await interaction.response.send_message(str(exc), ephemeral=True)
109
+ return
110
+ created = self.tmux_service.ensure_session(session_name)
111
+ self.tmux_service.set_session_environment(
112
+ session_name,
113
+ "TETHERLY_SESSION",
114
+ session_name,
115
+ )
116
+ self.tmux_service.set_session_environment(
117
+ session_name,
118
+ "TETHERLY_NOTIFY_ON_FINISH",
119
+ "1",
120
+ )
121
+ try:
122
+ binding = self.registry.bind(
123
+ guild_id=interaction.guild_id,
124
+ channel_id=interaction.channel_id,
125
+ session_name=session_name,
126
+ bound_by=interaction.user.id,
127
+ )
128
+ except SessionRegistryError as exc:
129
+ await interaction.response.send_message(str(exc), ephemeral=True)
130
+ return
131
+ verb = "Created and bound" if created else "Bound"
132
+ await interaction.response.send_message(
133
+ f"{verb} channel <#{binding.channel_id}> to tmux session `{binding.session_name}`.",
134
+ ephemeral=True,
135
+ )
136
+
137
+ @self.tree.command(
138
+ name="config",
139
+ description="Configure channel behavior for the bound tmux session.",
140
+ )
141
+ @app_commands.describe(auto_send="forward plain text messages without using /send")
142
+ async def config(interaction: discord.Interaction, auto_send: bool) -> None:
143
+ if not await self.access_controller.assert_allowed(interaction):
144
+ return
145
+ binding = self.registry.get(interaction.channel_id)
146
+ if binding is None:
147
+ await interaction.response.send_message(
148
+ "This channel is not bound. Run `/bind session:<name>` first.",
149
+ ephemeral=True,
150
+ )
151
+ return
152
+ updated = self.registry.set_auto_send(interaction.channel_id, auto_send)
153
+ status = "enabled" if auto_send else "disabled"
154
+ await interaction.response.send_message(
155
+ f"Auto-send {status} for `{updated.session_name}`.",
156
+ ephemeral=True,
157
+ )
158
+
159
+ @self.tree.command(name="send", description="Send text into the bound tmux session and press Enter.")
160
+ @app_commands.describe(text="text to send")
161
+ async def send(interaction: discord.Interaction, text: str) -> None:
162
+ if not await self.access_controller.assert_allowed(interaction):
163
+ return
164
+ binding = self.registry.get(interaction.channel_id)
165
+ if binding is None:
166
+ await interaction.response.send_message(
167
+ "This channel is not bound. Run `/bind session:<name>` first.",
168
+ ephemeral=True,
169
+ )
170
+ return
171
+ try:
172
+ self.tmux_service.send_text(binding.session_name, text, press_enter=True)
173
+ except TmuxError as exc:
174
+ await interaction.response.send_message(
175
+ f"Failed to send to `{binding.session_name}`: {exc}",
176
+ ephemeral=True,
177
+ )
178
+ return
179
+ self.registry.touch(interaction.channel_id)
180
+ await interaction.response.send_message(
181
+ f"Sent to `{binding.session_name}`.",
182
+ ephemeral=True,
183
+ )
184
+
185
+ @self.tree.command(name="key", description="Send a special key into the bound tmux session.")
186
+ @app_commands.describe(key="special key to send")
187
+ @app_commands.choices(
188
+ key=[
189
+ app_commands.Choice(name="Enter", value="enter"),
190
+ app_commands.Choice(name="Escape", value="esc"),
191
+ app_commands.Choice(name="Ctrl-C", value="ctrl-c"),
192
+ app_commands.Choice(name="Ctrl-D", value="ctrl-d"),
193
+ app_commands.Choice(name="Tab", value="tab"),
194
+ app_commands.Choice(name="Up", value="up"),
195
+ app_commands.Choice(name="Down", value="down"),
196
+ app_commands.Choice(name="Left", value="left"),
197
+ app_commands.Choice(name="Right", value="right"),
198
+ ]
199
+ )
200
+ async def key(interaction: discord.Interaction, key: app_commands.Choice[str]) -> None:
201
+ if not await self.access_controller.assert_allowed(interaction):
202
+ return
203
+ binding = self.registry.get(interaction.channel_id)
204
+ if binding is None:
205
+ await interaction.response.send_message(
206
+ "This channel is not bound. Run `/bind session:<name>` first.",
207
+ ephemeral=True,
208
+ )
209
+ return
210
+ try:
211
+ self.tmux_service.send_key(binding.session_name, key.value)
212
+ except TmuxError as exc:
213
+ await interaction.response.send_message(
214
+ f"Failed to send `{key.name}` to `{binding.session_name}`: {exc}",
215
+ ephemeral=True,
216
+ )
217
+ return
218
+ self.registry.touch(interaction.channel_id)
219
+ await interaction.response.send_message(
220
+ f"Sent `{key.name}` to `{binding.session_name}`.",
221
+ ephemeral=True,
222
+ )
223
+
224
+ @self.tree.command(name="tail", description="Show recent output from the bound tmux session.")
225
+ @app_commands.describe(lines="number of lines to fetch")
226
+ async def tail(interaction: discord.Interaction, lines: int | None = None) -> None:
227
+ if not await self.access_controller.assert_allowed(interaction):
228
+ return
229
+ binding = self.registry.get(interaction.channel_id)
230
+ if binding is None:
231
+ await interaction.response.send_message(
232
+ "This channel is not bound. Run `/bind session:<name>` first.",
233
+ ephemeral=True,
234
+ )
235
+ return
236
+ requested = lines or self.config.default_tail_lines
237
+ capped = min(max(1, requested), self.config.max_tail_lines)
238
+ try:
239
+ output = self.tmux_service.capture_tail(binding.session_name, capped)
240
+ except TmuxError as exc:
241
+ await interaction.response.send_message(
242
+ f"Failed to capture `{binding.session_name}`: {exc}",
243
+ ephemeral=True,
244
+ )
245
+ return
246
+ self.registry.touch(interaction.channel_id)
247
+ await interaction.response.send_message(
248
+ f"Recent output from `{binding.session_name}` ({capped} lines max)\n{_render_code_block(output)}",
249
+ ephemeral=True,
250
+ )
251
+
252
+ @self.tree.command(name="status", description="Show binding and tmux session status for this channel.")
253
+ async def status(interaction: discord.Interaction) -> None:
254
+ if not await self.access_controller.assert_allowed(interaction):
255
+ return
256
+ binding = self.registry.get(interaction.channel_id)
257
+ if binding is None:
258
+ await interaction.response.send_message(
259
+ "This channel is not bound.",
260
+ ephemeral=True,
261
+ )
262
+ return
263
+ tmux_status = self.tmux_service.get_status(binding.session_name)
264
+ if tmux_status.exists:
265
+ headline = f"🟢 Active — tmux session `{binding.session_name}` is alive"
266
+ else:
267
+ headline = (
268
+ f"🔴 tmux session `{binding.session_name}` is GONE — "
269
+ "run `/bind session:<name>` to reconnect"
270
+ )
271
+ await interaction.response.send_message(
272
+ "\n".join(
273
+ [
274
+ headline,
275
+ f"Channel: <#{binding.channel_id}>",
276
+ f"Auto-send: `{binding.auto_send}`",
277
+ f"Bound by: <@{binding.bound_by}>",
278
+ f"Bound at: `{binding.bound_at}`",
279
+ f"Last used at: `{binding.last_used_at}`",
280
+ ]
281
+ ),
282
+ ephemeral=True,
283
+ )