agentirc-cli 0.3.1__tar.gz → 0.4.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.
Files changed (121) hide show
  1. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/CHANGELOG.md +17 -0
  2. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/PKG-INFO +1 -1
  3. agentirc_cli-0.4.0/agentirc/__init__.py +1 -0
  4. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/cli.py +13 -4
  5. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/__main__.py +12 -4
  6. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/channel.py +2 -0
  7. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/client.py +40 -23
  8. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/config.py +1 -0
  9. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/ircd.py +3 -3
  10. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/server_link.py +78 -4
  11. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer4-federation.md +41 -5
  12. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/pyproject.toml +1 -1
  13. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_federation.py +318 -0
  14. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/uv.lock +1 -1
  15. agentirc_cli-0.3.1/agentirc/__init__.py +0 -1
  16. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.github/workflows/pages.yml +0 -0
  17. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.github/workflows/publish.yml +0 -0
  18. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.github/workflows/tests.yml +0 -0
  19. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.gitignore +0 -0
  20. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.markdownlint-cli2.yaml +0 -0
  21. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/.pr_agent.toml +0 -0
  22. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/CLAUDE.md +0 -0
  23. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/CNAME +0 -0
  24. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/Gemfile +0 -0
  25. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/Gemfile.lock +0 -0
  26. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/LICENSE +0 -0
  27. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/README.md +0 -0
  28. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/_config.yml +0 -0
  29. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/_sass/color_schemes/anthropic.scss +0 -0
  30. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/_sass/custom/custom.scss +0 -0
  31. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/__init__.py +0 -0
  32. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/__init__.py +0 -0
  33. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/__main__.py +0 -0
  34. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/agent_runner.py +0 -0
  35. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/config.py +0 -0
  36. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/daemon.py +0 -0
  37. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/ipc.py +0 -0
  38. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/irc_transport.py +0 -0
  39. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/message_buffer.py +0 -0
  40. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/skill/SKILL.md +0 -0
  41. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/skill/__init__.py +0 -0
  42. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/skill/irc_client.py +0 -0
  43. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/socket_server.py +0 -0
  44. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/supervisor.py +0 -0
  45. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/claude/webhook.py +0 -0
  46. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/codex/__init__.py +0 -0
  47. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/codex/skill/SKILL.md +0 -0
  48. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/clients/codex/skill/__init__.py +0 -0
  49. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/observer.py +0 -0
  50. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/pidfile.py +0 -0
  51. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/__init__.py +0 -0
  52. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/commands.py +0 -0
  53. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/extensions/federation.md +0 -0
  54. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/extensions/history.md +0 -0
  55. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/message.py +0 -0
  56. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/protocol-index.md +0 -0
  57. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/protocol/replies.py +0 -0
  58. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/__init__.py +0 -0
  59. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/remote_client.py +0 -0
  60. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/skill.py +0 -0
  61. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/skills/__init__.py +0 -0
  62. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/agentirc/server/skills/history.py +0 -0
  63. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/agent-client.md +0 -0
  64. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/ci.md +0 -0
  65. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/cli.md +0 -0
  66. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/configuration.md +0 -0
  67. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/context-management.md +0 -0
  68. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/irc-tools.md +0 -0
  69. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/overview.md +0 -0
  70. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/setup.md +0 -0
  71. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/supervisor.md +0 -0
  72. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/clients/claude/webhooks.md +0 -0
  73. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/design.md +0 -0
  74. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/docs-site.md +0 -0
  75. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/getting-started.md +0 -0
  76. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer1-core-irc.md +0 -0
  77. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer2-attention.md +0 -0
  78. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer3-skills.md +0 -0
  79. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/layer5-agent-harness.md +0 -0
  80. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/publishing.md +0 -0
  81. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/server-architecture.md +0 -0
  82. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/plans/2026-03-19-layer1-core-irc.md +0 -0
  83. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/plans/2026-03-21-layer5-agent-harness.md +0 -0
  84. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/specs/2026-03-19-agentirc-design.md +0 -0
  85. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/superpowers/specs/2026-03-21-layer5-agent-harness-design.md +0 -0
  86. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/01-pair-programming.md +0 -0
  87. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/02-code-review-ensemble.md +0 -0
  88. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/03-research-deep-dive.md +0 -0
  89. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/04-agent-delegation.md +0 -0
  90. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/05-benchmark-swarm.md +0 -0
  91. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/06-cross-server-ops.md +0 -0
  92. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/07-knowledge-pipeline.md +0 -0
  93. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/08-supervisor-intervention.md +0 -0
  94. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases/09-apps-as-agents.md +0 -0
  95. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/docs/use-cases-index.md +0 -0
  96. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/index.md +0 -0
  97. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/plugins/claude-code/.claude-plugin/plugin.json +0 -0
  98. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/plugins/claude-code/skills/irc/SKILL.md +0 -0
  99. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/plugins/codex/skills/agentirc-irc/SKILL.md +0 -0
  100. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/__init__.py +0 -0
  101. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/conftest.py +0 -0
  102. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_agent_runner.py +0 -0
  103. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_channel.py +0 -0
  104. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_connection.py +0 -0
  105. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_daemon.py +0 -0
  106. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_daemon_config.py +0 -0
  107. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_discovery.py +0 -0
  108. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_history.py +0 -0
  109. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_integration_layer5.py +0 -0
  110. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_ipc.py +0 -0
  111. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_irc_transport.py +0 -0
  112. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_mentions.py +0 -0
  113. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_message.py +0 -0
  114. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_message_buffer.py +0 -0
  115. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_messaging.py +0 -0
  116. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_modes.py +0 -0
  117. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_skill_client.py +0 -0
  118. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_skills.py +0 -0
  119. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_socket_server.py +0 -0
  120. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_supervisor.py +0 -0
  121. {agentirc_cli-0.3.1 → agentirc_cli-0.4.0}/tests/test_webhook.py +0 -0
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  Format follows [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.0] - 2026-03-24
8
+
9
+
10
+ ### Added
11
+
12
+ - Link trust levels: full (share all) and restricted (share nothing unless opted in)
13
+ - Channel mode +R: restricted — channel stays local, never federated
14
+ - Channel mode +S <server>: shared — explicitly share channel with named server
15
+ - Mutual +S required for restricted links — both sides must agree
16
+ - Safe default: inbound links from unknown peers default to restricted
17
+
18
+
19
+ ### Changed
20
+
21
+ - Link format extended: name:host:port:password:trust (trust defaults to full)
22
+ - Burst and relay filtered through should_relay() based on trust + channel modes
23
+
7
24
  ## [0.3.1] - 2026-03-22
8
25
 
9
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentirc-cli
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: IRC protocol chatrooms for AI agents (and humans allowed)
5
5
  Project-URL: Homepage, https://github.com/OriNachum/agentirc
6
6
  Author: Ori Nachum
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -36,20 +36,29 @@ logger = logging.getLogger("agentirc")
36
36
 
37
37
 
38
38
  def _parse_link(value: str):
39
- """Parse a link spec: name:host:port:password"""
39
+ """Parse a link spec: name:host:port:password[:trust]
40
+
41
+ Trust is extracted from the end if it matches a known value.
42
+ This allows passwords containing colons.
43
+ """
40
44
  from agentirc.server.config import LinkConfig
41
45
 
46
+ # Check if the last segment is a trust level
47
+ trust = "full"
48
+ if value.endswith(":full") or value.endswith(":restricted"):
49
+ value, trust = value.rsplit(":", 1)
50
+
42
51
  parts = value.split(":", 3)
43
52
  if len(parts) != 4:
44
53
  raise argparse.ArgumentTypeError(
45
- f"Link must be name:host:port:password, got: {value}"
54
+ f"Link must be name:host:port:password[:trust], got: {value}"
46
55
  )
47
56
  name, host, port_str, password = parts
48
57
  try:
49
58
  port = int(port_str)
50
59
  except ValueError:
51
60
  raise argparse.ArgumentTypeError(f"Invalid port: {port_str}")
52
- return LinkConfig(name=name, host=host, port=port, password=password)
61
+ return LinkConfig(name=name, host=host, port=port, password=password, trust=trust)
53
62
 
54
63
  DEFAULT_CONFIG = os.path.expanduser("~/.agentirc/agents.yaml")
55
64
  LOG_DIR = os.path.expanduser("~/.agentirc/logs")
@@ -247,7 +256,7 @@ async def _run_server(name: str, host: str, port: int, links: list | None = None
247
256
  # Connect to configured peers
248
257
  for lc in config.links:
249
258
  try:
250
- await ircd.connect_to_peer(lc.host, lc.port, lc.password)
259
+ await ircd.connect_to_peer(lc.host, lc.port, lc.password, lc.trust)
251
260
  logger.info("Linking to %s at %s:%d", lc.name, lc.host, lc.port)
252
261
  except Exception as e:
253
262
  logger.error("Failed to link to %s: %s", lc.name, e)
@@ -6,18 +6,26 @@ from agentirc.server.ircd import IRCd
6
6
 
7
7
 
8
8
  def parse_link(value: str) -> LinkConfig:
9
- """Parse a link spec: name:host:port:password"""
9
+ """Parse a link spec: name:host:port:password[:trust]
10
+
11
+ Trust is extracted from the end if it matches a known value.
12
+ This allows passwords containing colons.
13
+ """
14
+ trust = "full"
15
+ if value.endswith(":full") or value.endswith(":restricted"):
16
+ value, trust = value.rsplit(":", 1)
17
+
10
18
  parts = value.split(":", 3)
11
19
  if len(parts) != 4:
12
20
  raise argparse.ArgumentTypeError(
13
- f"Link must be name:host:port:password, got: {value}"
21
+ f"Link must be name:host:port:password[:trust], got: {value}"
14
22
  )
15
23
  name, host, port_str, password = parts
16
24
  try:
17
25
  port = int(port_str)
18
26
  except ValueError:
19
27
  raise argparse.ArgumentTypeError(f"Invalid port: {port_str}")
20
- return LinkConfig(name=name, host=host, port=port, password=password)
28
+ return LinkConfig(name=name, host=host, port=port, password=password, trust=trust)
21
29
 
22
30
 
23
31
  async def main() -> None:
@@ -44,7 +52,7 @@ async def main() -> None:
44
52
  # Connect to configured peers
45
53
  for lc in config.links:
46
54
  try:
47
- await ircd.connect_to_peer(lc.host, lc.port, lc.password)
55
+ await ircd.connect_to_peer(lc.host, lc.port, lc.password, lc.trust)
48
56
  print(f"Linking to {lc.name} at {lc.host}:{lc.port}")
49
57
  except Exception as e:
50
58
  print(f"Failed to link to {lc.name}: {e}")
@@ -17,6 +17,8 @@ class Channel:
17
17
  self.members: set[Client] = set()
18
18
  self.operators: set[Client] = set()
19
19
  self.voiced: set[Client] = set()
20
+ self.restricted = False # +R mode — never federate
21
+ self.shared_with: set[str] = set() # +S servers — share with these servers
20
22
 
21
23
  def _local_members(self) -> set[Client]:
22
24
  """Return only local (non-remote) members."""
@@ -391,41 +391,58 @@ class Client:
391
391
  return
392
392
 
393
393
  param_queue = list(msg.params[2:])
394
- param_modes = {"o", "v"}
394
+ param_modes = {"o", "v", "S"}
395
395
 
396
396
  adding = True
397
397
  applied_modes = []
398
- applied_nicks: list[str] = []
398
+ applied_params: list[str] = []
399
399
  for ch in modestring:
400
400
  if ch == "+":
401
401
  adding = True
402
402
  elif ch == "-":
403
403
  adding = False
404
+ elif ch == "R":
405
+ # +R / -R — restrict channel from federation
406
+ if adding:
407
+ channel.restricted = True
408
+ else:
409
+ channel.restricted = False
410
+ applied_modes.append(("+" if adding else "-") + ch)
404
411
  elif ch in param_modes:
405
412
  if not param_queue:
406
413
  continue
407
- target_nick = param_queue.pop(0)
408
- target_client = self.server.clients.get(target_nick)
409
- if not target_client or target_client not in channel.members:
410
- await self.send_numeric(
411
- replies.ERR_USERNOTINCHANNEL,
412
- target_nick,
413
- channel_name,
414
- "They aren't on that channel",
415
- )
416
- continue
417
- if ch == "o":
414
+ param_value = param_queue.pop(0)
415
+ if ch == "S":
416
+ # +S <server> / -S <server> share with specific servers
418
417
  if adding:
419
- channel.operators.add(target_client)
418
+ channel.shared_with.add(param_value)
420
419
  else:
421
- channel.operators.discard(target_client)
422
- elif ch == "v":
423
- if adding:
424
- channel.voiced.add(target_client)
425
- else:
426
- channel.voiced.discard(target_client)
427
- applied_modes.append(("+" if adding else "-") + ch)
428
- applied_nicks.append(target_nick)
420
+ channel.shared_with.discard(param_value)
421
+ applied_modes.append(("+" if adding else "-") + ch)
422
+ applied_params.append(param_value)
423
+ else:
424
+ target_nick = param_value
425
+ target_client = self.server.clients.get(target_nick)
426
+ if not target_client or target_client not in channel.members:
427
+ await self.send_numeric(
428
+ replies.ERR_USERNOTINCHANNEL,
429
+ target_nick,
430
+ channel_name,
431
+ "They aren't on that channel",
432
+ )
433
+ continue
434
+ if ch == "o":
435
+ if adding:
436
+ channel.operators.add(target_client)
437
+ else:
438
+ channel.operators.discard(target_client)
439
+ elif ch == "v":
440
+ if adding:
441
+ channel.voiced.add(target_client)
442
+ else:
443
+ channel.voiced.discard(target_client)
444
+ applied_modes.append(("+" if adding else "-") + ch)
445
+ applied_params.append(target_nick)
429
446
 
430
447
  # Auto-promote if no operators remain
431
448
  if not channel.operators and channel.members:
@@ -435,7 +452,7 @@ class Client:
435
452
  mode_msg = Message(
436
453
  prefix=self.prefix,
437
454
  command="MODE",
438
- params=[channel_name, "".join(applied_modes)] + applied_nicks,
455
+ params=[channel_name, "".join(applied_modes)] + applied_params,
439
456
  )
440
457
  for member in list(channel.members):
441
458
  await member.send(mode_msg)
@@ -9,6 +9,7 @@ class LinkConfig:
9
9
  host: str
10
10
  port: int
11
11
  password: str
12
+ trust: str = "full" # "full" or "restricted"
12
13
 
13
14
 
14
15
  @dataclass
@@ -102,13 +102,13 @@ class IRCd:
102
102
  await self._server.wait_closed()
103
103
 
104
104
  async def connect_to_peer(
105
- self, host: str, port: int, password: str
105
+ self, host: str, port: int, password: str, trust: str = "full"
106
106
  ) -> ServerLink:
107
107
  """Initiate an outbound S2S connection."""
108
108
  from agentirc.server.server_link import ServerLink
109
109
 
110
110
  reader, writer = await asyncio.open_connection(host, port)
111
- link = ServerLink(reader, writer, self, password, initiator=True)
111
+ link = ServerLink(reader, writer, self, password, initiator=True, trust=trust)
112
112
  asyncio.create_task(link.handle())
113
113
  return link
114
114
 
@@ -139,7 +139,7 @@ class IRCd:
139
139
  writer.close()
140
140
  return
141
141
 
142
- link = ServerLink(reader, writer, self, password=None, initiator=False)
142
+ link = ServerLink(reader, writer, self, password=None, initiator=False, trust="restricted")
143
143
  try:
144
144
  await link.handle(initial_msg=text)
145
145
  except (ConnectionError, asyncio.IncompleteReadError):
@@ -23,15 +23,17 @@ class ServerLink:
23
23
  reader: asyncio.StreamReader,
24
24
  writer: asyncio.StreamWriter,
25
25
  server: IRCd,
26
- password: str,
26
+ password: str | None,
27
27
  *,
28
28
  initiator: bool = False,
29
+ trust: str = "full",
29
30
  ):
30
31
  self.reader = reader
31
32
  self.writer = writer
32
33
  self.server = server
33
34
  self.password = password
34
35
  self.initiator = initiator
36
+ self.trust = trust
35
37
  self.peer_name: str | None = None
36
38
  self.peer_description: str = ""
37
39
  self._authenticated = False
@@ -40,6 +42,19 @@ class ServerLink:
40
42
  self._peer_pass: str | None = None
41
43
  self.last_seen_seq: int = 0
42
44
 
45
+ def should_relay(self, channel_name: str) -> bool:
46
+ """Check if a channel event should be relayed over this link."""
47
+ channel = self.server.channels.get(channel_name)
48
+ if channel is None:
49
+ return False
50
+ if channel.restricted:
51
+ return False
52
+ if self.trust == "full":
53
+ return True
54
+ if self.trust == "restricted":
55
+ return self.peer_name in channel.shared_with
56
+ return False
57
+
43
58
  async def send_raw(self, line: str) -> None:
44
59
  try:
45
60
  self.writer.write(f"{line}\r\n".encode("utf-8"))
@@ -121,7 +136,7 @@ class ServerLink:
121
136
  if not (self._got_pass and self._got_server):
122
137
  return
123
138
 
124
- # For inbound links, look up expected password by peer name
139
+ # For inbound links, look up expected password and trust by peer name
125
140
  if not self.initiator and self.password is None:
126
141
  link_config = None
127
142
  for lc in self.server.config.links:
@@ -133,6 +148,7 @@ class ServerLink:
133
148
  await self.send_raw(f"ERROR :No link configured for {self.peer_name}")
134
149
  raise ConnectionError(f"No link config for {self.peer_name}")
135
150
  self.password = link_config.password
151
+ self.trust = link_config.trust
136
152
 
137
153
  if self._peer_pass != self.password:
138
154
  logger.warning("Bad password from peer %s", self.peer_name)
@@ -172,8 +188,10 @@ class ServerLink:
172
188
  f"SNICK {client.nick} {client.user} {client.host} :{client.realname}"
173
189
  )
174
190
 
175
- # Send channel membership
191
+ # Send channel membership (filtered by trust)
176
192
  for channel in self.server.channels.values():
193
+ if not self.should_relay(channel.name):
194
+ continue
177
195
  local_nicks = [
178
196
  m.nick for m in channel.members
179
197
  if not isinstance(m, RemoteClient)
@@ -182,8 +200,10 @@ class ServerLink:
182
200
  nicks_str = " ".join(local_nicks)
183
201
  await self.send_raw(f"SJOIN {channel.name} {nicks_str}")
184
202
 
185
- # Send channel topics
203
+ # Send channel topics (filtered by trust)
186
204
  for channel in self.server.channels.values():
205
+ if not self.should_relay(channel.name):
206
+ continue
187
207
  if channel.topic:
188
208
  # Find who set it (use first local member as setter)
189
209
  local_members = [
@@ -229,6 +249,19 @@ class ServerLink:
229
249
  channel_name = msg.params[0]
230
250
  nicks = msg.params[1:]
231
251
 
252
+ # Check incoming trust: if we have a restricted trust for this peer,
253
+ # only accept channel data for channels that have +S <peer>
254
+ existing = self.server.channels.get(channel_name)
255
+ if existing:
256
+ if existing.restricted:
257
+ return
258
+ if self.trust == "restricted" and self.peer_name not in existing.shared_with:
259
+ return
260
+ else:
261
+ # Channel doesn't exist locally yet — restricted links cannot create new channels
262
+ if self.trust == "restricted":
263
+ return
264
+
232
265
  channel = self.server.get_or_create_channel(channel_name)
233
266
 
234
267
  for nick in nicks:
@@ -255,6 +288,11 @@ class ServerLink:
255
288
 
256
289
  channel = self.server.channels.get(channel_name)
257
290
  if channel:
291
+ # Check incoming trust
292
+ if channel.restricted:
293
+ return
294
+ if self.trust == "restricted" and self.peer_name not in channel.shared_with:
295
+ return
258
296
  channel.topic = topic
259
297
 
260
298
  # --- Real-time relay handlers (incoming from peer) ---
@@ -267,6 +305,15 @@ class ServerLink:
267
305
  sender_nick = msg.params[1]
268
306
  text = msg.params[2]
269
307
 
308
+ # Check incoming trust for channel messages
309
+ if target.startswith("#"):
310
+ channel = self.server.channels.get(target)
311
+ if channel:
312
+ if channel.restricted:
313
+ return
314
+ if self.trust == "restricted" and self.peer_name not in channel.shared_with:
315
+ return
316
+
270
317
  relay = Message(
271
318
  prefix=f"{sender_nick}!*@*",
272
319
  command="PRIVMSG",
@@ -322,6 +369,15 @@ class ServerLink:
322
369
  sender_nick = msg.params[1]
323
370
  text = msg.params[2]
324
371
 
372
+ # Check incoming trust for channel notices
373
+ if target.startswith("#"):
374
+ channel = self.server.channels.get(target)
375
+ if channel:
376
+ if channel.restricted:
377
+ return
378
+ if self.trust == "restricted" and self.peer_name not in channel.shared_with:
379
+ return
380
+
325
381
  rc = self.server.remote_clients.get(sender_nick)
326
382
  prefix = rc.prefix if rc else f"{sender_nick}!*@*"
327
383
  relay = Message(prefix=prefix, command="NOTICE", params=[target, text])
@@ -361,6 +417,12 @@ class ServerLink:
361
417
  if not channel:
362
418
  return
363
419
 
420
+ # Check incoming trust
421
+ if channel.restricted:
422
+ return
423
+ if self.trust == "restricted" and self.peer_name not in channel.shared_with:
424
+ return
425
+
364
426
  # Notify local members
365
427
  part_params = [channel_name, reason] if reason else [channel_name]
366
428
  part_msg = Message(prefix=rc.prefix, command="PART", params=part_params)
@@ -454,6 +516,9 @@ class ServerLink:
454
516
  if event.type == EventType.MESSAGE:
455
517
  target = event.channel or event.data.get("target", "")
456
518
  text = event.data.get("text", "")
519
+ # Filter channel messages through trust check
520
+ if target.startswith("#") and not self.should_relay(target):
521
+ return
457
522
  cmd = event.data.get("notice") and "SNOTICE" or "SMSG"
458
523
  await self.send_raw(
459
524
  f":{origin} {cmd} {target} {event.nick} :{text}"
@@ -469,6 +534,9 @@ class ServerLink:
469
534
  if event.type == EventType.MESSAGE:
470
535
  target = event.channel or event.data.get("target", "")
471
536
  text = event.data.get("text", "")
537
+ # Filter channel messages through trust check
538
+ if target.startswith("#") and not self.should_relay(target):
539
+ return
472
540
  if event.data.get("notice"):
473
541
  await self.send_raw(
474
542
  f":{origin} SNOTICE {target} {event.nick} :{text}"
@@ -479,11 +547,15 @@ class ServerLink:
479
547
  )
480
548
  elif event.type == EventType.JOIN:
481
549
  channel_name = event.channel
550
+ if not self.should_relay(channel_name):
551
+ return
482
552
  await self.send_raw(
483
553
  f":{origin} SJOIN {channel_name} {event.nick}"
484
554
  )
485
555
  elif event.type == EventType.PART:
486
556
  channel_name = event.channel
557
+ if not self.should_relay(channel_name):
558
+ return
487
559
  reason = event.data.get("reason", "")
488
560
  await self.send_raw(
489
561
  f":{origin} SPART {channel_name} {event.nick} :{reason}"
@@ -495,6 +567,8 @@ class ServerLink:
495
567
  )
496
568
  elif event.type == EventType.TOPIC:
497
569
  channel_name = event.channel
570
+ if not self.should_relay(channel_name):
571
+ return
498
572
  topic = event.data.get("topic", "")
499
573
  await self.send_raw(
500
574
  f":{origin} STOPIC {channel_name} {event.nick} :{topic}"
@@ -63,22 +63,57 @@ agentirc server start --name thor --port 6668 --link spark:localhost:6667:secret
63
63
 
64
64
  ### Link Format
65
65
 
66
+ ```text
67
+ --link name:host:port:password[:trust]
66
68
  ```
67
- --link name:host:port:password
69
+
70
+ Trust is `full` (default) or `restricted`:
71
+
72
+ - **full** — share all channels (except `+R` restricted ones). Use for trusted
73
+ home mesh servers.
74
+ - **restricted** — share nothing unless both sides explicitly agree with `+S`.
75
+ Use for external or public servers.
76
+
77
+ ```bash
78
+ # Home mesh — full trust (default)
79
+ agentirc server start --name spark --port 6667 --link thor:machineB:6667:secret
80
+
81
+ # Public server — restricted trust
82
+ agentirc server start --name spark --port 6667 --link public:example.com:6667:pubpass:restricted
68
83
  ```
69
84
 
85
+ ### Channel Federation Modes
86
+
87
+ | Mode | Meaning |
88
+ |------|---------|
89
+ | `+R` | Restricted — channel stays local, never shared (even on full links) |
90
+ | `+S <server>` | Shared — share this channel with the named server |
91
+ | `-R` | Remove restricted flag |
92
+ | `-S <server>` | Stop sharing with server |
93
+
94
+ Examples:
95
+
96
+ ```text
97
+ MODE #internal +R # keep this channel local
98
+ MODE #collab +S public-server # share with public-server
99
+ MODE #collab -S public-server # stop sharing
100
+ ```
101
+
102
+ For restricted links, **both sides** must set `+S` for a channel to sync.
103
+ This prevents one server from unilaterally pulling channels from another.
104
+
70
105
  ### Programmatic
71
106
 
72
107
  ```python
73
- await server_a.connect_to_peer("localhost", 6668, "shared_secret")
108
+ await server_a.connect_to_peer("localhost", 6668, "shared_secret", trust="full")
74
109
  ```
75
110
 
76
111
  ## What Syncs
77
112
 
78
113
  - Client presence (SNICK on registration and burst)
79
- - Channel membership (SJOIN/SPART)
80
- - Messages (SMSG/SNOTICE)
81
- - Topics (STOPIC)
114
+ - Channel membership (SJOIN/SPART) — filtered by trust and channel modes
115
+ - Messages (SMSG/SNOTICE) — filtered by trust and channel modes
116
+ - Topics (STOPIC) — filtered by trust and channel modes
82
117
  - Client disconnects (SQUITUSER)
83
118
  - @mention notifications across servers
84
119
 
@@ -87,6 +122,7 @@ await server_a.connect_to_peer("localhost", 6668, "shared_secret")
87
122
  - Authentication
88
123
  - Skills data (populated independently via synced events)
89
124
  - Channel modes/operators (local authority only)
125
+ - Channels marked `+R` (restricted)
90
126
 
91
127
  ## Wire Protocol
92
128
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentirc-cli"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "IRC protocol chatrooms for AI agents (and humans allowed)"
5
5
  readme = "README.md"
6
6
  license = "MIT"