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.
Files changed (42) hide show
  1. agentirc/__init__.py +1 -0
  2. agentirc/cli.py +651 -0
  3. agentirc/clients/__init__.py +0 -0
  4. agentirc/clients/claude/__init__.py +0 -0
  5. agentirc/clients/claude/__main__.py +93 -0
  6. agentirc/clients/claude/agent_runner.py +167 -0
  7. agentirc/clients/claude/config.py +162 -0
  8. agentirc/clients/claude/daemon.py +422 -0
  9. agentirc/clients/claude/ipc.py +38 -0
  10. agentirc/clients/claude/irc_transport.py +146 -0
  11. agentirc/clients/claude/message_buffer.py +46 -0
  12. agentirc/clients/claude/skill/SKILL.md +202 -0
  13. agentirc/clients/claude/skill/__init__.py +0 -0
  14. agentirc/clients/claude/skill/irc_client.py +281 -0
  15. agentirc/clients/claude/socket_server.py +106 -0
  16. agentirc/clients/claude/supervisor.py +139 -0
  17. agentirc/clients/claude/webhook.py +59 -0
  18. agentirc/observer.py +228 -0
  19. agentirc/pidfile.py +49 -0
  20. agentirc/protocol/__init__.py +0 -0
  21. agentirc/protocol/commands.py +33 -0
  22. agentirc/protocol/extensions/federation.md +94 -0
  23. agentirc/protocol/extensions/history.md +112 -0
  24. agentirc/protocol/message.py +58 -0
  25. agentirc/protocol/protocol-index.md +9 -0
  26. agentirc/protocol/replies.py +44 -0
  27. agentirc/server/__init__.py +0 -0
  28. agentirc/server/__main__.py +61 -0
  29. agentirc/server/channel.py +56 -0
  30. agentirc/server/client.py +742 -0
  31. agentirc/server/config.py +21 -0
  32. agentirc/server/ircd.py +208 -0
  33. agentirc/server/remote_client.py +42 -0
  34. agentirc/server/server_link.py +537 -0
  35. agentirc/server/skill.py +45 -0
  36. agentirc/server/skills/__init__.py +0 -0
  37. agentirc/server/skills/history.py +152 -0
  38. agentirc_cli-0.2.1.dist-info/METADATA +183 -0
  39. agentirc_cli-0.2.1.dist-info/RECORD +42 -0
  40. agentirc_cli-0.2.1.dist-info/WHEEL +4 -0
  41. agentirc_cli-0.2.1.dist-info/entry_points.txt +2 -0
  42. 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)
@@ -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