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
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