agentirc-cli 0.2.1__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.
- agentirc/__init__.py +1 -0
- agentirc/cli.py +651 -0
- agentirc/clients/__init__.py +0 -0
- agentirc/clients/claude/__init__.py +0 -0
- agentirc/clients/claude/__main__.py +93 -0
- agentirc/clients/claude/agent_runner.py +167 -0
- agentirc/clients/claude/config.py +162 -0
- agentirc/clients/claude/daemon.py +422 -0
- agentirc/clients/claude/ipc.py +38 -0
- agentirc/clients/claude/irc_transport.py +146 -0
- agentirc/clients/claude/message_buffer.py +46 -0
- agentirc/clients/claude/skill/SKILL.md +202 -0
- agentirc/clients/claude/skill/__init__.py +0 -0
- agentirc/clients/claude/skill/irc_client.py +281 -0
- agentirc/clients/claude/socket_server.py +106 -0
- agentirc/clients/claude/supervisor.py +139 -0
- agentirc/clients/claude/webhook.py +59 -0
- agentirc/observer.py +228 -0
- agentirc/pidfile.py +49 -0
- agentirc/protocol/__init__.py +0 -0
- agentirc/protocol/commands.py +33 -0
- agentirc/protocol/extensions/federation.md +94 -0
- agentirc/protocol/extensions/history.md +112 -0
- agentirc/protocol/message.py +58 -0
- agentirc/protocol/protocol-index.md +9 -0
- agentirc/protocol/replies.py +44 -0
- agentirc/server/__init__.py +0 -0
- agentirc/server/__main__.py +61 -0
- agentirc/server/channel.py +56 -0
- agentirc/server/client.py +742 -0
- agentirc/server/config.py +21 -0
- agentirc/server/ircd.py +208 -0
- agentirc/server/remote_client.py +42 -0
- agentirc/server/server_link.py +537 -0
- agentirc/server/skill.py +45 -0
- agentirc/server/skills/__init__.py +0 -0
- agentirc/server/skills/history.py +152 -0
- agentirc_cli-0.2.1.dist-info/METADATA +183 -0
- agentirc_cli-0.2.1.dist-info/RECORD +42 -0
- agentirc_cli-0.2.1.dist-info/WHEEL +4 -0
- agentirc_cli-0.2.1.dist-info/entry_points.txt +2 -0
- agentirc_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class LinkConfig:
|
|
6
|
+
"""Configuration for a server-to-server link."""
|
|
7
|
+
|
|
8
|
+
name: str
|
|
9
|
+
host: str
|
|
10
|
+
port: int
|
|
11
|
+
password: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ServerConfig:
|
|
16
|
+
"""Configuration for an agentirc server instance."""
|
|
17
|
+
|
|
18
|
+
name: str = "agentirc"
|
|
19
|
+
host: str = "0.0.0.0"
|
|
20
|
+
port: int = 6667
|
|
21
|
+
links: list[LinkConfig] = field(default_factory=list)
|
agentirc/server/ircd.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# server/ircd.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from collections import deque
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from agentirc.server.config import ServerConfig
|
|
10
|
+
from agentirc.server.channel import Channel
|
|
11
|
+
from agentirc.server.skill import Event, Skill
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from agentirc.server.client import Client
|
|
15
|
+
from agentirc.server.remote_client import RemoteClient
|
|
16
|
+
from agentirc.server.server_link import ServerLink
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IRCd:
|
|
20
|
+
"""The agentirc IRC server."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: ServerConfig):
|
|
23
|
+
self.config = config
|
|
24
|
+
self.clients: dict[str, Client] = {} # nick -> Client
|
|
25
|
+
self.channels: dict[str, Channel] = {} # name -> Channel
|
|
26
|
+
self.skills: list[Skill] = []
|
|
27
|
+
self._server: asyncio.Server | None = None
|
|
28
|
+
# Federation
|
|
29
|
+
self.links: dict[str, ServerLink] = {} # peer_name -> ServerLink
|
|
30
|
+
self.remote_clients: dict[str, RemoteClient] = {} # nick -> RemoteClient
|
|
31
|
+
self._seq: int = 0
|
|
32
|
+
self._event_log: deque[tuple[int, Event]] = deque(maxlen=10000)
|
|
33
|
+
self._peer_acked_seq: dict[str, int] = {} # peer_name -> our _seq when link last dropped
|
|
34
|
+
|
|
35
|
+
async def start(self) -> None:
|
|
36
|
+
await self._register_default_skills()
|
|
37
|
+
self._server = await asyncio.start_server(
|
|
38
|
+
self._handle_connection,
|
|
39
|
+
self.config.host,
|
|
40
|
+
self.config.port,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
async def _register_default_skills(self) -> None:
|
|
44
|
+
from agentirc.server.skills.history import HistorySkill
|
|
45
|
+
|
|
46
|
+
await self.register_skill(HistorySkill())
|
|
47
|
+
|
|
48
|
+
async def register_skill(self, skill: Skill) -> None:
|
|
49
|
+
self.skills.append(skill)
|
|
50
|
+
await skill.start(self)
|
|
51
|
+
|
|
52
|
+
def next_seq(self) -> int:
|
|
53
|
+
self._seq += 1
|
|
54
|
+
return self._seq
|
|
55
|
+
|
|
56
|
+
async def emit_event(self, event: Event) -> None:
|
|
57
|
+
# Log event with sequence number
|
|
58
|
+
seq = self.next_seq()
|
|
59
|
+
self._event_log.append((seq, event))
|
|
60
|
+
|
|
61
|
+
for skill in self.skills:
|
|
62
|
+
try:
|
|
63
|
+
await skill.on_event(event)
|
|
64
|
+
except Exception:
|
|
65
|
+
logging.getLogger(__name__).exception(
|
|
66
|
+
"Skill %s failed on event %s", skill.name, event.type
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Relay to linked peers — only relay locally-originated events
|
|
70
|
+
# (no mesh routing; scope is direct peers only)
|
|
71
|
+
if not event.data.get("_origin"):
|
|
72
|
+
for peer_name, link in list(self.links.items()):
|
|
73
|
+
try:
|
|
74
|
+
await link.relay_event(event)
|
|
75
|
+
except Exception:
|
|
76
|
+
logging.getLogger(__name__).exception(
|
|
77
|
+
"Failed to relay event to %s", peer_name
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def get_skill_for_command(self, command: str) -> Skill | None:
|
|
81
|
+
for skill in self.skills:
|
|
82
|
+
if command in skill.commands:
|
|
83
|
+
return skill
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def get_client(self, nick: str) -> Client | RemoteClient | None:
|
|
87
|
+
"""Look up a client by nick, checking both local and remote."""
|
|
88
|
+
return self.clients.get(nick) or self.remote_clients.get(nick)
|
|
89
|
+
|
|
90
|
+
async def stop(self) -> None:
|
|
91
|
+
for skill in self.skills:
|
|
92
|
+
await skill.stop()
|
|
93
|
+
# Close all S2S links
|
|
94
|
+
for link in list(self.links.values()):
|
|
95
|
+
try:
|
|
96
|
+
link.writer.close()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
self.links.clear()
|
|
100
|
+
if self._server:
|
|
101
|
+
self._server.close()
|
|
102
|
+
await self._server.wait_closed()
|
|
103
|
+
|
|
104
|
+
async def connect_to_peer(
|
|
105
|
+
self, host: str, port: int, password: str
|
|
106
|
+
) -> ServerLink:
|
|
107
|
+
"""Initiate an outbound S2S connection."""
|
|
108
|
+
from agentirc.server.server_link import ServerLink
|
|
109
|
+
|
|
110
|
+
reader, writer = await asyncio.open_connection(host, port)
|
|
111
|
+
link = ServerLink(reader, writer, self, password, initiator=True)
|
|
112
|
+
asyncio.create_task(link.handle())
|
|
113
|
+
return link
|
|
114
|
+
|
|
115
|
+
async def _handle_connection(
|
|
116
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Peek at first message to detect S2S vs C2S."""
|
|
119
|
+
from agentirc.server.client import Client
|
|
120
|
+
from agentirc.server.server_link import ServerLink
|
|
121
|
+
from agentirc.protocol.message import Message
|
|
122
|
+
|
|
123
|
+
# Read first line to detect connection type
|
|
124
|
+
first_data = await reader.read(4096)
|
|
125
|
+
if not first_data:
|
|
126
|
+
writer.close()
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
text = first_data.decode("utf-8", errors="replace")
|
|
130
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
131
|
+
first_line = text.split("\n", 1)[0].strip()
|
|
132
|
+
msg = Message.parse(first_line)
|
|
133
|
+
|
|
134
|
+
if msg.command == "PASS":
|
|
135
|
+
# S2S connection - password validated after SERVER reveals peer name
|
|
136
|
+
if not self.config.links:
|
|
137
|
+
writer.write(b"ERROR :No links configured\r\n")
|
|
138
|
+
await writer.drain()
|
|
139
|
+
writer.close()
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
link = ServerLink(reader, writer, self, password=None, initiator=False)
|
|
143
|
+
try:
|
|
144
|
+
await link.handle(initial_msg=text)
|
|
145
|
+
except (ConnectionError, asyncio.IncompleteReadError):
|
|
146
|
+
pass
|
|
147
|
+
else:
|
|
148
|
+
# C2S connection
|
|
149
|
+
client = Client(reader, writer, self)
|
|
150
|
+
try:
|
|
151
|
+
await client.handle(initial_msg=text)
|
|
152
|
+
except (ConnectionError, asyncio.IncompleteReadError):
|
|
153
|
+
pass
|
|
154
|
+
finally:
|
|
155
|
+
self._remove_client(client)
|
|
156
|
+
writer.close()
|
|
157
|
+
try:
|
|
158
|
+
await writer.wait_closed()
|
|
159
|
+
except (ConnectionError, BrokenPipeError):
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
def _remove_client(self, client: Client) -> None:
|
|
163
|
+
if client.nick and client.nick in self.clients:
|
|
164
|
+
del self.clients[client.nick]
|
|
165
|
+
for channel in list(client.channels):
|
|
166
|
+
channel.remove(client)
|
|
167
|
+
if not channel.members:
|
|
168
|
+
del self.channels[channel.name]
|
|
169
|
+
|
|
170
|
+
def _remove_link(self, link: ServerLink) -> None:
|
|
171
|
+
"""Remove a S2S link and all its remote clients."""
|
|
172
|
+
from agentirc.protocol.message import Message
|
|
173
|
+
from agentirc.server.remote_client import RemoteClient
|
|
174
|
+
|
|
175
|
+
peer_name = link.peer_name
|
|
176
|
+
if peer_name and peer_name in self.links:
|
|
177
|
+
del self.links[peer_name]
|
|
178
|
+
# Persist our current seq -- peer saw everything up to here via real-time relay
|
|
179
|
+
self._peer_acked_seq[peer_name] = self._seq
|
|
180
|
+
|
|
181
|
+
# Find all remote clients from this link
|
|
182
|
+
to_remove = [
|
|
183
|
+
nick for nick, rc in self.remote_clients.items()
|
|
184
|
+
if rc.link is link
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
for nick in to_remove:
|
|
188
|
+
rc = self.remote_clients[nick]
|
|
189
|
+
quit_msg = Message(
|
|
190
|
+
prefix=rc.prefix, command="QUIT", params=["Server link closed"]
|
|
191
|
+
)
|
|
192
|
+
notified: set = set()
|
|
193
|
+
for channel in list(rc.channels):
|
|
194
|
+
for member in list(channel.members):
|
|
195
|
+
if not isinstance(member, RemoteClient) and member not in notified:
|
|
196
|
+
asyncio.ensure_future(member.send(quit_msg))
|
|
197
|
+
notified.add(member)
|
|
198
|
+
channel.members.discard(rc)
|
|
199
|
+
if not channel.members:
|
|
200
|
+
if channel.name in self.channels:
|
|
201
|
+
del self.channels[channel.name]
|
|
202
|
+
rc.channels.clear()
|
|
203
|
+
del self.remote_clients[nick]
|
|
204
|
+
|
|
205
|
+
def get_or_create_channel(self, name: str) -> Channel:
|
|
206
|
+
if name not in self.channels:
|
|
207
|
+
self.channels[name] = Channel(name)
|
|
208
|
+
return self.channels[name]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# server/remote_client.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from agentirc.protocol.message import Message
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from agentirc.server.channel import Channel
|
|
10
|
+
from agentirc.server.server_link import ServerLink
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RemoteClient:
|
|
14
|
+
"""Ghost of a client connected to a peer server.
|
|
15
|
+
|
|
16
|
+
Lives in channel.members so NAMES/WHO/WHOIS work transparently.
|
|
17
|
+
send() is a no-op -- relay happens at the event/link level.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
nick: str,
|
|
23
|
+
user: str,
|
|
24
|
+
host: str,
|
|
25
|
+
realname: str,
|
|
26
|
+
server_name: str,
|
|
27
|
+
link: ServerLink,
|
|
28
|
+
):
|
|
29
|
+
self.nick = nick
|
|
30
|
+
self.user = user
|
|
31
|
+
self.host = host
|
|
32
|
+
self.realname = realname
|
|
33
|
+
self.server_name = server_name
|
|
34
|
+
self.link = link
|
|
35
|
+
self.channels: set[Channel] = set()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def prefix(self) -> str:
|
|
39
|
+
return f"{self.nick}!{self.user}@{self.host}"
|
|
40
|
+
|
|
41
|
+
async def send(self, message: Message) -> None:
|
|
42
|
+
pass # No-op: relay happens through ServerLink
|