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
agentirc/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.1"
|
agentirc/cli.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Unified CLI entry point for agentirc.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
agentirc server start|stop|status Manage the IRC server daemon
|
|
5
|
+
agentirc init Register an agent for the current directory
|
|
6
|
+
agentirc start [nick] [--all] Start agent daemon(s)
|
|
7
|
+
agentirc stop [nick] [--all] Stop agent daemon(s)
|
|
8
|
+
agentirc status List running agents
|
|
9
|
+
agentirc read <channel> Read recent channel messages
|
|
10
|
+
agentirc who <channel> List channel members
|
|
11
|
+
agentirc channels List active channels
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import signal
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from agentirc.clients.claude.config import (
|
|
25
|
+
AgentConfig,
|
|
26
|
+
DaemonConfig,
|
|
27
|
+
ServerConnConfig,
|
|
28
|
+
add_agent_to_config,
|
|
29
|
+
load_config,
|
|
30
|
+
load_config_or_default,
|
|
31
|
+
sanitize_agent_name,
|
|
32
|
+
)
|
|
33
|
+
from agentirc.pidfile import is_process_alive, read_pid, remove_pid, write_pid
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("agentirc")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_link(value: str):
|
|
39
|
+
"""Parse a link spec: name:host:port:password"""
|
|
40
|
+
from agentirc.server.config import LinkConfig
|
|
41
|
+
|
|
42
|
+
parts = value.split(":", 3)
|
|
43
|
+
if len(parts) != 4:
|
|
44
|
+
raise argparse.ArgumentTypeError(
|
|
45
|
+
f"Link must be name:host:port:password, got: {value}"
|
|
46
|
+
)
|
|
47
|
+
name, host, port_str, password = parts
|
|
48
|
+
try:
|
|
49
|
+
port = int(port_str)
|
|
50
|
+
except ValueError:
|
|
51
|
+
raise argparse.ArgumentTypeError(f"Invalid port: {port_str}")
|
|
52
|
+
return LinkConfig(name=name, host=host, port=port, password=password)
|
|
53
|
+
|
|
54
|
+
DEFAULT_CONFIG = os.path.expanduser("~/.agentirc/agents.yaml")
|
|
55
|
+
LOG_DIR = os.path.expanduser("~/.agentirc/logs")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# -----------------------------------------------------------------------
|
|
59
|
+
# Main entry point
|
|
60
|
+
# -----------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
parser = argparse.ArgumentParser(
|
|
64
|
+
prog="agentirc",
|
|
65
|
+
description="agentirc — AI agent IRC mesh",
|
|
66
|
+
)
|
|
67
|
+
sub = parser.add_subparsers(dest="command")
|
|
68
|
+
|
|
69
|
+
# -- server subcommand -------------------------------------------------
|
|
70
|
+
server_parser = sub.add_parser("server", help="Manage the IRC server")
|
|
71
|
+
server_sub = server_parser.add_subparsers(dest="server_command")
|
|
72
|
+
|
|
73
|
+
srv_start = server_sub.add_parser("start", help="Start the IRC server daemon")
|
|
74
|
+
srv_start.add_argument("--name", default="agentirc", help="Server name")
|
|
75
|
+
srv_start.add_argument("--host", default="0.0.0.0", help="Listen address")
|
|
76
|
+
srv_start.add_argument("--port", type=int, default=6667, help="Listen port")
|
|
77
|
+
srv_start.add_argument(
|
|
78
|
+
"--link", type=_parse_link, action="append", default=[],
|
|
79
|
+
help="Link to peer: name:host:port:password",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
srv_stop = server_sub.add_parser("stop", help="Stop the IRC server daemon")
|
|
83
|
+
srv_stop.add_argument("--name", default="agentirc", help="Server name")
|
|
84
|
+
|
|
85
|
+
srv_status = server_sub.add_parser("status", help="Check server daemon status")
|
|
86
|
+
srv_status.add_argument("--name", default="agentirc", help="Server name")
|
|
87
|
+
|
|
88
|
+
# -- init subcommand ---------------------------------------------------
|
|
89
|
+
init_parser = sub.add_parser("init", help="Register an agent for the current directory")
|
|
90
|
+
init_parser.add_argument("--server", default=None, help="Server name prefix")
|
|
91
|
+
init_parser.add_argument("--nick", default=None, help="Agent suffix (after server-)")
|
|
92
|
+
init_parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
|
|
93
|
+
|
|
94
|
+
# -- start subcommand --------------------------------------------------
|
|
95
|
+
start_parser = sub.add_parser("start", help="Start agent daemon(s)")
|
|
96
|
+
start_parser.add_argument("nick", nargs="?", help="Agent nick to start")
|
|
97
|
+
start_parser.add_argument("--all", action="store_true", help="Start all agents")
|
|
98
|
+
start_parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
|
|
99
|
+
|
|
100
|
+
# -- stop subcommand ---------------------------------------------------
|
|
101
|
+
stop_parser = sub.add_parser("stop", help="Stop agent daemon(s)")
|
|
102
|
+
stop_parser.add_argument("nick", nargs="?", help="Agent nick to stop")
|
|
103
|
+
stop_parser.add_argument("--all", action="store_true", help="Stop all agents")
|
|
104
|
+
stop_parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
|
|
105
|
+
|
|
106
|
+
# -- status subcommand -------------------------------------------------
|
|
107
|
+
status_parser = sub.add_parser("status", help="List running agents")
|
|
108
|
+
status_parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
|
|
109
|
+
|
|
110
|
+
# -- read subcommand ---------------------------------------------------
|
|
111
|
+
read_parser = sub.add_parser("read", help="Read recent channel messages")
|
|
112
|
+
read_parser.add_argument("channel", help="Channel name (e.g. #general)")
|
|
113
|
+
read_parser.add_argument("--limit", "-n", type=int, default=50, help="Number of messages")
|
|
114
|
+
read_parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
|
|
115
|
+
|
|
116
|
+
# -- who subcommand ----------------------------------------------------
|
|
117
|
+
who_parser = sub.add_parser("who", help="List members of a channel")
|
|
118
|
+
who_parser.add_argument("channel", help="Channel or nick target")
|
|
119
|
+
who_parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
|
|
120
|
+
|
|
121
|
+
# -- channels subcommand -----------------------------------------------
|
|
122
|
+
channels_parser = sub.add_parser("channels", help="List active channels")
|
|
123
|
+
channels_parser.add_argument("--config", default=DEFAULT_CONFIG, help="Config file path")
|
|
124
|
+
|
|
125
|
+
args = parser.parse_args()
|
|
126
|
+
|
|
127
|
+
if args.command is None:
|
|
128
|
+
parser.print_help()
|
|
129
|
+
sys.exit(1)
|
|
130
|
+
|
|
131
|
+
logging.basicConfig(
|
|
132
|
+
level=logging.INFO,
|
|
133
|
+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
dispatch = {
|
|
138
|
+
"server": _cmd_server,
|
|
139
|
+
"init": _cmd_init,
|
|
140
|
+
"start": _cmd_start,
|
|
141
|
+
"stop": _cmd_stop,
|
|
142
|
+
"status": _cmd_status,
|
|
143
|
+
"read": _cmd_read,
|
|
144
|
+
"who": _cmd_who,
|
|
145
|
+
"channels": _cmd_channels,
|
|
146
|
+
}
|
|
147
|
+
handler = dispatch.get(args.command)
|
|
148
|
+
if handler:
|
|
149
|
+
handler(args)
|
|
150
|
+
else:
|
|
151
|
+
parser.print_help()
|
|
152
|
+
sys.exit(1)
|
|
153
|
+
except KeyboardInterrupt:
|
|
154
|
+
pass
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
157
|
+
sys.exit(1)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# -----------------------------------------------------------------------
|
|
161
|
+
# Server subcommands
|
|
162
|
+
# -----------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _cmd_server(args: argparse.Namespace) -> None:
|
|
165
|
+
if not args.server_command:
|
|
166
|
+
print("Usage: agentirc server {start|stop|status}", file=sys.stderr)
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
|
|
169
|
+
if args.server_command == "start":
|
|
170
|
+
_server_start(args)
|
|
171
|
+
elif args.server_command == "stop":
|
|
172
|
+
_server_stop(args)
|
|
173
|
+
elif args.server_command == "status":
|
|
174
|
+
_server_status(args)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _server_start(args: argparse.Namespace) -> None:
|
|
178
|
+
pid_name = f"server-{args.name}"
|
|
179
|
+
|
|
180
|
+
# Check if already running
|
|
181
|
+
existing = read_pid(pid_name)
|
|
182
|
+
if existing and is_process_alive(existing):
|
|
183
|
+
print(f"Server '{args.name}' is already running (PID {existing})")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
# Fork to daemonize
|
|
187
|
+
pid = os.fork()
|
|
188
|
+
if pid > 0:
|
|
189
|
+
# Parent: wait briefly to check child started, then exit
|
|
190
|
+
time.sleep(0.2)
|
|
191
|
+
if is_process_alive(pid):
|
|
192
|
+
print(f"Server '{args.name}' started (PID {pid})")
|
|
193
|
+
print(f" Listening on {args.host}:{args.port}")
|
|
194
|
+
print(f" Logs: {LOG_DIR}/server-{args.name}.log")
|
|
195
|
+
else:
|
|
196
|
+
print(f"Server '{args.name}' failed to start", file=sys.stderr)
|
|
197
|
+
sys.exit(1)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
# Child: detach from parent session
|
|
201
|
+
os.setsid()
|
|
202
|
+
|
|
203
|
+
# Redirect stdout/stderr to log file
|
|
204
|
+
os.makedirs(LOG_DIR, exist_ok=True)
|
|
205
|
+
log_path = os.path.join(LOG_DIR, f"server-{args.name}.log")
|
|
206
|
+
log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
207
|
+
os.dup2(log_fd, 1)
|
|
208
|
+
os.dup2(log_fd, 2)
|
|
209
|
+
os.close(log_fd)
|
|
210
|
+
|
|
211
|
+
# Close stdin
|
|
212
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
213
|
+
os.dup2(devnull, 0)
|
|
214
|
+
os.close(devnull)
|
|
215
|
+
|
|
216
|
+
# Write PID file
|
|
217
|
+
write_pid(pid_name, os.getpid())
|
|
218
|
+
|
|
219
|
+
# Run the server
|
|
220
|
+
try:
|
|
221
|
+
asyncio.run(_run_server(args.name, args.host, args.port, args.link))
|
|
222
|
+
finally:
|
|
223
|
+
remove_pid(pid_name)
|
|
224
|
+
os._exit(0)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _run_server(name: str, host: str, port: int, links: list | None = None) -> None:
|
|
228
|
+
"""Run the IRC server (called in the daemon child process)."""
|
|
229
|
+
from agentirc.server.config import ServerConfig
|
|
230
|
+
from agentirc.server.ircd import IRCd
|
|
231
|
+
|
|
232
|
+
config = ServerConfig(name=name, host=host, port=port, links=links or [])
|
|
233
|
+
ircd = IRCd(config)
|
|
234
|
+
await ircd.start()
|
|
235
|
+
logger.info("Server '%s' listening on %s:%d", name, host, port)
|
|
236
|
+
|
|
237
|
+
# Connect to configured peers
|
|
238
|
+
for lc in config.links:
|
|
239
|
+
try:
|
|
240
|
+
await ircd.connect_to_peer(lc.host, lc.port, lc.password)
|
|
241
|
+
logger.info("Linking to %s at %s:%d", lc.name, lc.host, lc.port)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.error("Failed to link to %s: %s", lc.name, e)
|
|
244
|
+
|
|
245
|
+
stop_event = asyncio.Event()
|
|
246
|
+
loop = asyncio.get_event_loop()
|
|
247
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
248
|
+
loop.add_signal_handler(sig, stop_event.set)
|
|
249
|
+
|
|
250
|
+
await stop_event.wait()
|
|
251
|
+
logger.info("Server '%s' shutting down", name)
|
|
252
|
+
await ircd.stop()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _server_stop(args: argparse.Namespace) -> None:
|
|
256
|
+
pid_name = f"server-{args.name}"
|
|
257
|
+
pid = read_pid(pid_name)
|
|
258
|
+
|
|
259
|
+
if pid is None:
|
|
260
|
+
print(f"No PID file for server '{args.name}'")
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
|
|
263
|
+
if not is_process_alive(pid):
|
|
264
|
+
print(f"Server '{args.name}' is not running (stale PID {pid})")
|
|
265
|
+
remove_pid(pid_name)
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
print(f"Stopping server '{args.name}' (PID {pid})...")
|
|
269
|
+
os.kill(pid, signal.SIGTERM)
|
|
270
|
+
|
|
271
|
+
# Wait up to 5 seconds for graceful shutdown
|
|
272
|
+
for _ in range(50):
|
|
273
|
+
if not is_process_alive(pid):
|
|
274
|
+
print(f"Server '{args.name}' stopped")
|
|
275
|
+
remove_pid(pid_name)
|
|
276
|
+
return
|
|
277
|
+
time.sleep(0.1)
|
|
278
|
+
|
|
279
|
+
# Force kill
|
|
280
|
+
print(f"Server '{args.name}' did not stop gracefully, sending SIGKILL")
|
|
281
|
+
try:
|
|
282
|
+
os.kill(pid, signal.SIGKILL)
|
|
283
|
+
except ProcessLookupError:
|
|
284
|
+
pass
|
|
285
|
+
remove_pid(pid_name)
|
|
286
|
+
print(f"Server '{args.name}' killed")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _server_status(args: argparse.Namespace) -> None:
|
|
290
|
+
pid_name = f"server-{args.name}"
|
|
291
|
+
pid = read_pid(pid_name)
|
|
292
|
+
|
|
293
|
+
if pid is None:
|
|
294
|
+
print(f"Server '{args.name}': not running (no PID file)")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
if is_process_alive(pid):
|
|
298
|
+
print(f"Server '{args.name}': running (PID {pid})")
|
|
299
|
+
else:
|
|
300
|
+
print(f"Server '{args.name}': not running (stale PID {pid})")
|
|
301
|
+
remove_pid(pid_name)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# -----------------------------------------------------------------------
|
|
305
|
+
# Agent init
|
|
306
|
+
# -----------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def _cmd_init(args: argparse.Namespace) -> None:
|
|
309
|
+
config = load_config_or_default(args.config)
|
|
310
|
+
|
|
311
|
+
# Determine server name
|
|
312
|
+
server_name = args.server or config.server.name or "agentirc"
|
|
313
|
+
|
|
314
|
+
# Determine agent suffix
|
|
315
|
+
if args.nick:
|
|
316
|
+
suffix = args.nick
|
|
317
|
+
else:
|
|
318
|
+
dirname = os.path.basename(os.getcwd())
|
|
319
|
+
suffix = sanitize_agent_name(dirname)
|
|
320
|
+
|
|
321
|
+
full_nick = f"{server_name}-{suffix}"
|
|
322
|
+
|
|
323
|
+
# Check for collision
|
|
324
|
+
for existing in config.agents:
|
|
325
|
+
if existing.nick == full_nick:
|
|
326
|
+
print(f"Agent '{full_nick}' already exists in config")
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
|
|
329
|
+
agent = AgentConfig(
|
|
330
|
+
nick=full_nick,
|
|
331
|
+
directory=os.getcwd(),
|
|
332
|
+
channels=["#general"],
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
add_agent_to_config(args.config, agent, server_name=server_name)
|
|
336
|
+
|
|
337
|
+
print(f"Agent registered: {full_nick}")
|
|
338
|
+
print(f" Directory: {agent.directory}")
|
|
339
|
+
print(f" Channels: {', '.join(agent.channels)}")
|
|
340
|
+
print(f" Config: {args.config}")
|
|
341
|
+
print()
|
|
342
|
+
print(f"Start with: agentirc start {full_nick}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# -----------------------------------------------------------------------
|
|
346
|
+
# Agent start
|
|
347
|
+
# -----------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
def _cmd_start(args: argparse.Namespace) -> None:
|
|
350
|
+
config = load_config(args.config)
|
|
351
|
+
|
|
352
|
+
if args.all:
|
|
353
|
+
agents = config.agents
|
|
354
|
+
elif args.nick:
|
|
355
|
+
agent = config.get_agent(args.nick)
|
|
356
|
+
if not agent:
|
|
357
|
+
print(f"Agent '{args.nick}' not found in config", file=sys.stderr)
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
agents = [agent]
|
|
360
|
+
else:
|
|
361
|
+
# Auto-select if exactly one agent configured
|
|
362
|
+
if len(config.agents) == 1:
|
|
363
|
+
agents = config.agents
|
|
364
|
+
elif len(config.agents) == 0:
|
|
365
|
+
print("No agents configured. Run 'agentirc init' first.", file=sys.stderr)
|
|
366
|
+
sys.exit(1)
|
|
367
|
+
else:
|
|
368
|
+
print(
|
|
369
|
+
"Multiple agents configured. Specify a nick or use --all.",
|
|
370
|
+
file=sys.stderr,
|
|
371
|
+
)
|
|
372
|
+
for a in config.agents:
|
|
373
|
+
print(f" {a.nick}", file=sys.stderr)
|
|
374
|
+
sys.exit(1)
|
|
375
|
+
|
|
376
|
+
if not agents:
|
|
377
|
+
print("No agents configured", file=sys.stderr)
|
|
378
|
+
sys.exit(1)
|
|
379
|
+
|
|
380
|
+
if len(agents) == 1:
|
|
381
|
+
# Run in foreground (single agent)
|
|
382
|
+
agent = agents[0]
|
|
383
|
+
print(f"Starting agent {agent.nick}...")
|
|
384
|
+
asyncio.run(_run_single_agent(config, agent))
|
|
385
|
+
else:
|
|
386
|
+
# Fork each agent into background
|
|
387
|
+
_run_multi_agents(config, agents)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def _run_single_agent(config: DaemonConfig, agent: AgentConfig) -> None:
|
|
391
|
+
"""Run a single agent daemon in the foreground."""
|
|
392
|
+
from agentirc.clients.claude.daemon import AgentDaemon
|
|
393
|
+
|
|
394
|
+
daemon = AgentDaemon(config, agent)
|
|
395
|
+
|
|
396
|
+
stop_event = asyncio.Event()
|
|
397
|
+
daemon.set_stop_event(stop_event)
|
|
398
|
+
|
|
399
|
+
await daemon.start()
|
|
400
|
+
logger.info("Agent %s started", agent.nick)
|
|
401
|
+
|
|
402
|
+
loop = asyncio.get_event_loop()
|
|
403
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
404
|
+
loop.add_signal_handler(sig, stop_event.set)
|
|
405
|
+
|
|
406
|
+
await stop_event.wait()
|
|
407
|
+
logger.info("Shutting down %s", agent.nick)
|
|
408
|
+
await daemon.stop()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _run_multi_agents(config: DaemonConfig, agents: list[AgentConfig]) -> None:
|
|
412
|
+
"""Fork each agent into its own background process."""
|
|
413
|
+
for agent in agents:
|
|
414
|
+
pid = os.fork()
|
|
415
|
+
if pid == 0:
|
|
416
|
+
# Child: detach and run
|
|
417
|
+
os.setsid()
|
|
418
|
+
|
|
419
|
+
# Redirect output to log
|
|
420
|
+
os.makedirs(LOG_DIR, exist_ok=True)
|
|
421
|
+
log_path = os.path.join(LOG_DIR, f"agent-{agent.nick}.log")
|
|
422
|
+
log_fd = os.open(log_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
|
|
423
|
+
os.dup2(log_fd, 1)
|
|
424
|
+
os.dup2(log_fd, 2)
|
|
425
|
+
os.close(log_fd)
|
|
426
|
+
|
|
427
|
+
devnull = os.open(os.devnull, os.O_RDONLY)
|
|
428
|
+
os.dup2(devnull, 0)
|
|
429
|
+
os.close(devnull)
|
|
430
|
+
|
|
431
|
+
pid_name = f"agent-{agent.nick}"
|
|
432
|
+
write_pid(pid_name, os.getpid())
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
asyncio.run(_run_single_agent(config, agent))
|
|
436
|
+
finally:
|
|
437
|
+
remove_pid(pid_name)
|
|
438
|
+
os._exit(0)
|
|
439
|
+
else:
|
|
440
|
+
print(f"Started {agent.nick} (PID {pid})")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# -----------------------------------------------------------------------
|
|
444
|
+
# Agent stop
|
|
445
|
+
# -----------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
def _cmd_stop(args: argparse.Namespace) -> None:
|
|
448
|
+
config = load_config_or_default(args.config)
|
|
449
|
+
|
|
450
|
+
if args.all:
|
|
451
|
+
agents = config.agents
|
|
452
|
+
elif args.nick:
|
|
453
|
+
agent = config.get_agent(args.nick)
|
|
454
|
+
if not agent:
|
|
455
|
+
print(f"Agent '{args.nick}' not found in config", file=sys.stderr)
|
|
456
|
+
sys.exit(1)
|
|
457
|
+
agents = [agent]
|
|
458
|
+
else:
|
|
459
|
+
if len(config.agents) == 1:
|
|
460
|
+
agents = config.agents
|
|
461
|
+
elif len(config.agents) == 0:
|
|
462
|
+
print("No agents configured", file=sys.stderr)
|
|
463
|
+
sys.exit(1)
|
|
464
|
+
else:
|
|
465
|
+
print(
|
|
466
|
+
"Multiple agents configured. Specify a nick or use --all.",
|
|
467
|
+
file=sys.stderr,
|
|
468
|
+
)
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
|
|
471
|
+
for agent in agents:
|
|
472
|
+
_stop_agent(agent.nick)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _stop_agent(nick: str) -> None:
|
|
476
|
+
"""Stop a single agent by trying IPC shutdown first, then PID file."""
|
|
477
|
+
# Try Unix socket IPC shutdown
|
|
478
|
+
socket_path = os.path.join(
|
|
479
|
+
os.environ.get("XDG_RUNTIME_DIR", "/tmp"),
|
|
480
|
+
f"agentirc-{nick}.sock",
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
if os.path.exists(socket_path):
|
|
484
|
+
try:
|
|
485
|
+
success = asyncio.run(_ipc_shutdown(socket_path))
|
|
486
|
+
if success:
|
|
487
|
+
print(f"Agent '{nick}' shutdown requested via IPC")
|
|
488
|
+
# Wait for process to exit
|
|
489
|
+
pid_name = f"agent-{nick}"
|
|
490
|
+
pid = read_pid(pid_name)
|
|
491
|
+
if pid:
|
|
492
|
+
for _ in range(50):
|
|
493
|
+
if not is_process_alive(pid):
|
|
494
|
+
remove_pid(pid_name)
|
|
495
|
+
print(f"Agent '{nick}' stopped")
|
|
496
|
+
return
|
|
497
|
+
time.sleep(0.1)
|
|
498
|
+
# If still alive after 5s, fall through to SIGTERM
|
|
499
|
+
else:
|
|
500
|
+
print(f"Agent '{nick}' stopped")
|
|
501
|
+
return
|
|
502
|
+
except Exception:
|
|
503
|
+
pass # Fall through to PID-based stop
|
|
504
|
+
|
|
505
|
+
# Fall back to PID file
|
|
506
|
+
pid_name = f"agent-{nick}"
|
|
507
|
+
pid = read_pid(pid_name)
|
|
508
|
+
|
|
509
|
+
if pid is None:
|
|
510
|
+
print(f"No PID file for agent '{nick}'")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
if not is_process_alive(pid):
|
|
514
|
+
print(f"Agent '{nick}' is not running (stale PID {pid})")
|
|
515
|
+
remove_pid(pid_name)
|
|
516
|
+
return
|
|
517
|
+
|
|
518
|
+
print(f"Stopping agent '{nick}' (PID {pid})...")
|
|
519
|
+
os.kill(pid, signal.SIGTERM)
|
|
520
|
+
|
|
521
|
+
for _ in range(50):
|
|
522
|
+
if not is_process_alive(pid):
|
|
523
|
+
print(f"Agent '{nick}' stopped")
|
|
524
|
+
remove_pid(pid_name)
|
|
525
|
+
return
|
|
526
|
+
time.sleep(0.1)
|
|
527
|
+
|
|
528
|
+
# Force kill
|
|
529
|
+
print(f"Agent '{nick}' did not stop gracefully, sending SIGKILL")
|
|
530
|
+
try:
|
|
531
|
+
os.kill(pid, signal.SIGKILL)
|
|
532
|
+
except ProcessLookupError:
|
|
533
|
+
pass
|
|
534
|
+
remove_pid(pid_name)
|
|
535
|
+
print(f"Agent '{nick}' killed")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
async def _ipc_shutdown(socket_path: str) -> bool:
|
|
539
|
+
"""Send a shutdown command via Unix socket IPC."""
|
|
540
|
+
from agentirc.clients.claude.ipc import decode_message, encode_message, make_request
|
|
541
|
+
|
|
542
|
+
reader, writer = await asyncio.wait_for(
|
|
543
|
+
asyncio.open_unix_connection(socket_path),
|
|
544
|
+
timeout=3.0,
|
|
545
|
+
)
|
|
546
|
+
try:
|
|
547
|
+
req = make_request("shutdown")
|
|
548
|
+
writer.write(encode_message(req))
|
|
549
|
+
await writer.drain()
|
|
550
|
+
data = await asyncio.wait_for(reader.readline(), timeout=3.0)
|
|
551
|
+
resp = decode_message(data)
|
|
552
|
+
return resp is not None and resp.get("ok", False)
|
|
553
|
+
finally:
|
|
554
|
+
writer.close()
|
|
555
|
+
try:
|
|
556
|
+
await writer.wait_closed()
|
|
557
|
+
except (ConnectionError, BrokenPipeError, OSError):
|
|
558
|
+
pass
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# -----------------------------------------------------------------------
|
|
562
|
+
# Agent status
|
|
563
|
+
# -----------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
def _cmd_status(args: argparse.Namespace) -> None:
|
|
566
|
+
config = load_config_or_default(args.config)
|
|
567
|
+
|
|
568
|
+
if not config.agents:
|
|
569
|
+
print("No agents configured")
|
|
570
|
+
return
|
|
571
|
+
|
|
572
|
+
print(f"{'NICK':<30} {'STATUS':<12} {'PID':<10}")
|
|
573
|
+
print("-" * 52)
|
|
574
|
+
|
|
575
|
+
for agent in config.agents:
|
|
576
|
+
pid_name = f"agent-{agent.nick}"
|
|
577
|
+
pid = read_pid(pid_name)
|
|
578
|
+
status = "stopped"
|
|
579
|
+
|
|
580
|
+
if pid and is_process_alive(pid):
|
|
581
|
+
# Also check if socket is connectable
|
|
582
|
+
socket_path = os.path.join(
|
|
583
|
+
os.environ.get("XDG_RUNTIME_DIR", "/tmp"),
|
|
584
|
+
f"agentirc-{agent.nick}.sock",
|
|
585
|
+
)
|
|
586
|
+
if os.path.exists(socket_path):
|
|
587
|
+
status = "running"
|
|
588
|
+
else:
|
|
589
|
+
status = "starting"
|
|
590
|
+
print(f"{agent.nick:<30} {status:<12} {pid:<10}")
|
|
591
|
+
elif pid:
|
|
592
|
+
remove_pid(pid_name)
|
|
593
|
+
print(f"{agent.nick:<30} {'stopped':<12} {'-':<10}")
|
|
594
|
+
else:
|
|
595
|
+
print(f"{agent.nick:<30} {'stopped':<12} {'-':<10}")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# -----------------------------------------------------------------------
|
|
599
|
+
# Observation subcommands
|
|
600
|
+
# -----------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
def _get_observer(config_path: str):
|
|
603
|
+
"""Create an IRCObserver from the config file."""
|
|
604
|
+
from agentirc.observer import IRCObserver
|
|
605
|
+
|
|
606
|
+
config = load_config_or_default(config_path)
|
|
607
|
+
return IRCObserver(
|
|
608
|
+
host=config.server.host,
|
|
609
|
+
port=config.server.port,
|
|
610
|
+
server_name=config.server.name,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _cmd_read(args: argparse.Namespace) -> None:
|
|
615
|
+
observer = _get_observer(args.config)
|
|
616
|
+
channel = args.channel if args.channel.startswith("#") else f"#{args.channel}"
|
|
617
|
+
messages = asyncio.run(observer.read_channel(channel, limit=args.limit))
|
|
618
|
+
|
|
619
|
+
if not messages:
|
|
620
|
+
print(f"No messages in {channel}")
|
|
621
|
+
return
|
|
622
|
+
|
|
623
|
+
for msg in messages:
|
|
624
|
+
print(msg)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _cmd_who(args: argparse.Namespace) -> None:
|
|
628
|
+
observer = _get_observer(args.config)
|
|
629
|
+
target = args.channel
|
|
630
|
+
nicks = asyncio.run(observer.who(target))
|
|
631
|
+
|
|
632
|
+
if not nicks:
|
|
633
|
+
print(f"No users in {target}")
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
print(f"Users in {target}:")
|
|
637
|
+
for nick in nicks:
|
|
638
|
+
print(f" {nick}")
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _cmd_channels(args: argparse.Namespace) -> None:
|
|
642
|
+
observer = _get_observer(args.config)
|
|
643
|
+
channels = asyncio.run(observer.list_channels())
|
|
644
|
+
|
|
645
|
+
if not channels:
|
|
646
|
+
print("No active channels")
|
|
647
|
+
return
|
|
648
|
+
|
|
649
|
+
print("Active channels:")
|
|
650
|
+
for ch in channels:
|
|
651
|
+
print(f" {ch}")
|
|
File without changes
|
|
File without changes
|