inter-agent-pi 0.2.0__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.
@@ -0,0 +1 @@
1
+ """Pi adapter package."""
inter_agent_pi/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from collections.abc import Sequence
5
+
6
+ from inter_agent_pi import commands
7
+
8
+
9
+ def build_parser() -> argparse.ArgumentParser:
10
+ parser = argparse.ArgumentParser(prog="inter-agent-pi")
11
+ sub = parser.add_subparsers(dest="command", required=True)
12
+
13
+ connect = sub.add_parser("connect")
14
+ connect.add_argument("name")
15
+ connect.add_argument("--label")
16
+
17
+ send = sub.add_parser("send")
18
+ send.add_argument("to")
19
+ send.add_argument("text")
20
+ send.add_argument("--from", dest="from_name")
21
+
22
+ broadcast = sub.add_parser("broadcast")
23
+ broadcast.add_argument("text")
24
+ broadcast.add_argument("--from", dest="from_name")
25
+
26
+ subscribe = sub.add_parser("subscribe")
27
+ subscribe.add_argument("channel")
28
+ subscribe.add_argument("--name", required=True)
29
+
30
+ unsubscribe = sub.add_parser("unsubscribe")
31
+ unsubscribe.add_argument("channel")
32
+ unsubscribe.add_argument("--name", required=True)
33
+
34
+ kick = sub.add_parser("kick")
35
+ kick.add_argument("name")
36
+
37
+ publish = sub.add_parser("publish")
38
+ publish.add_argument("channel")
39
+ publish.add_argument("text")
40
+ publish.add_argument("--from", dest="from_name")
41
+
42
+ channels = sub.add_parser("channels")
43
+ channels.add_argument("--json", action="store_true", help="emit JSON protocol output")
44
+
45
+ list_parser = sub.add_parser("list")
46
+ list_parser.add_argument("--json", action="store_true", help="emit JSON protocol output")
47
+
48
+ status = sub.add_parser("status")
49
+ status.add_argument("--json", action="store_true", help="emit JSON status output")
50
+
51
+ sub.add_parser("shutdown")
52
+ return parser
53
+
54
+
55
+ def main(argv: Sequence[str] | None = None) -> int:
56
+ parser = build_parser()
57
+ args = parser.parse_args(argv)
58
+
59
+ if args.command == "connect":
60
+ return commands.connect(args.name, args.label)
61
+ if args.command == "send":
62
+ return commands.send(args.to, args.text, args.from_name)
63
+ if args.command == "broadcast":
64
+ return commands.broadcast(args.text, args.from_name)
65
+ if args.command == "subscribe":
66
+ return commands.subscribe(args.channel, args.name)
67
+ if args.command == "unsubscribe":
68
+ return commands.unsubscribe(args.channel, args.name)
69
+ if args.command == "kick":
70
+ return commands.kick(args.name)
71
+ if args.command == "publish":
72
+ return commands.publish(args.channel, args.text, args.from_name)
73
+ if args.command == "channels":
74
+ return commands.channels(as_json=args.json)
75
+ if args.command == "list":
76
+ return commands.list_sessions()
77
+ if args.command == "status":
78
+ print(commands.status_json())
79
+ return 0
80
+ if args.command == "shutdown":
81
+ return commands.shutdown()
82
+
83
+ parser.print_help()
84
+ return 2
85
+
86
+
87
+ if __name__ == "__main__":
88
+ raise SystemExit(main())
@@ -0,0 +1,348 @@
1
+ """Pi adapter wrappers around importable core command APIs.
2
+
3
+ Core supports `list`; adapter surfaces may choose whether to expose it.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+ import sys
11
+
12
+ from inter_agent.core import adapter_control as control
13
+ from inter_agent.core import channels as core_channels
14
+ from inter_agent.core import kick as core_kick
15
+ from inter_agent.core import list as core_list
16
+ from inter_agent.core import publish as core_publish
17
+ from inter_agent.core import send as core_send
18
+ from inter_agent.core import shutdown as core_shutdown
19
+ from inter_agent.core import status as core_status
20
+ from inter_agent.core.send import SendResult
21
+ from inter_agent.core.shared import Limits, resolve_endpoint, validate_channel_name
22
+ from websockets.exceptions import WebSocketException
23
+
24
+ from inter_agent_pi import listener
25
+
26
+
27
+ def _system_exit_code(exc: SystemExit) -> int:
28
+ if isinstance(exc.code, int):
29
+ return exc.code
30
+ if exc.code is not None:
31
+ print(exc.code, file=sys.stderr)
32
+ return 1
33
+
34
+
35
+ def _expected_error_code(exc: Exception) -> int:
36
+ print(f"inter-agent-pi: {exc}", file=sys.stderr)
37
+ return 1
38
+
39
+
40
+ def _send_result_code(result: SendResult) -> int:
41
+ if result.error is not None:
42
+ print(
43
+ f"inter-agent-pi: delivery failed ({result.error.code}): {result.error.message}",
44
+ file=sys.stderr,
45
+ )
46
+ return 1
47
+ print(result.welcome)
48
+ return 0
49
+
50
+
51
+ def connect(name: str, label: str | None = None) -> int:
52
+ try:
53
+ endpoint = resolve_endpoint(allow_discovery=True)
54
+ return asyncio.run(
55
+ listener.run_listener(
56
+ endpoint.host,
57
+ endpoint.port,
58
+ name,
59
+ label,
60
+ tls=endpoint.tls,
61
+ data_dir=endpoint.data_dir,
62
+ tls_cert_path=endpoint.tls_cert_path,
63
+ tls_key_path=endpoint.tls_key_path,
64
+ )
65
+ )
66
+ except SystemExit as exc:
67
+ return _system_exit_code(exc)
68
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
69
+ return _expected_error_code(exc)
70
+
71
+
72
+ def send(to: str, text: str, from_name: str | None = None) -> int:
73
+ try:
74
+ endpoint = resolve_endpoint(allow_discovery=True)
75
+ result = asyncio.run(
76
+ core_send.send_direct_message(
77
+ endpoint.host,
78
+ endpoint.port,
79
+ to,
80
+ text,
81
+ from_name,
82
+ tls=endpoint.tls,
83
+ data_dir=endpoint.data_dir,
84
+ tls_cert_path=endpoint.tls_cert_path,
85
+ )
86
+ )
87
+ except SystemExit as exc:
88
+ return _system_exit_code(exc)
89
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
90
+ return _expected_error_code(exc)
91
+ return _send_result_code(result)
92
+
93
+
94
+ def broadcast(text: str, from_name: str | None = None) -> int:
95
+ try:
96
+ endpoint = resolve_endpoint(allow_discovery=True)
97
+ result = asyncio.run(
98
+ core_send.broadcast_message(
99
+ endpoint.host,
100
+ endpoint.port,
101
+ text,
102
+ from_name,
103
+ tls=endpoint.tls,
104
+ data_dir=endpoint.data_dir,
105
+ tls_cert_path=endpoint.tls_cert_path,
106
+ )
107
+ )
108
+ except SystemExit as exc:
109
+ return _system_exit_code(exc)
110
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
111
+ return _expected_error_code(exc)
112
+ return _send_result_code(result)
113
+
114
+
115
+ def _control_response_code(response: dict[str, object]) -> int:
116
+ op = response.get("op")
117
+ if op in ("subscribe_ok", "unsubscribe_ok"):
118
+ print(json.dumps(response, ensure_ascii=False))
119
+ return 0
120
+ if op == "error":
121
+ code = response.get("code", "PROTOCOL_ERROR")
122
+ message = response.get("message", "protocol error")
123
+ print(f"inter-agent-pi: ({code}): {message}", file=sys.stderr)
124
+ return 1
125
+ print(f"inter-agent-pi: unexpected response: {response}", file=sys.stderr)
126
+ return 1
127
+
128
+
129
+ def _validate_channel_or_error(channel: str) -> bool:
130
+ if not validate_channel_name(channel, Limits().channel_name_max):
131
+ print(f"inter-agent-pi: invalid channel name: {channel!r}", file=sys.stderr)
132
+ return False
133
+ return True
134
+
135
+
136
+ def subscribe(channel: str, name: str) -> int:
137
+ """Subscribe the named live listener to a channel."""
138
+ if not _validate_channel_or_error(channel):
139
+ return 1
140
+ try:
141
+ endpoint = resolve_endpoint(allow_discovery=True)
142
+ response = asyncio.run(
143
+ control.request(
144
+ "pi",
145
+ endpoint.host,
146
+ endpoint.port,
147
+ name,
148
+ listener.pi_data_dir(),
149
+ "subscribe",
150
+ channel,
151
+ )
152
+ )
153
+ except (
154
+ SystemExit,
155
+ control.ControlError,
156
+ OSError,
157
+ TimeoutError,
158
+ ValueError,
159
+ WebSocketException,
160
+ ) as exc:
161
+ print(f"inter-agent-pi: {exc}", file=sys.stderr)
162
+ return 1
163
+ return _control_response_code(response)
164
+
165
+
166
+ def unsubscribe(channel: str, name: str) -> int:
167
+ """Unsubscribe the named live listener from a channel."""
168
+ if not _validate_channel_or_error(channel):
169
+ return 1
170
+ try:
171
+ endpoint = resolve_endpoint(allow_discovery=True)
172
+ response = asyncio.run(
173
+ control.request(
174
+ "pi",
175
+ endpoint.host,
176
+ endpoint.port,
177
+ name,
178
+ listener.pi_data_dir(),
179
+ "unsubscribe",
180
+ channel,
181
+ )
182
+ )
183
+ except (
184
+ SystemExit,
185
+ control.ControlError,
186
+ OSError,
187
+ TimeoutError,
188
+ ValueError,
189
+ WebSocketException,
190
+ ) as exc:
191
+ print(f"inter-agent-pi: {exc}", file=sys.stderr)
192
+ return 1
193
+ return _control_response_code(response)
194
+
195
+
196
+ def publish(channel: str, text: str, from_name: str | None = None) -> int:
197
+ if not _validate_channel_or_error(channel):
198
+ return 1
199
+ try:
200
+ endpoint = resolve_endpoint(allow_discovery=True)
201
+ result = asyncio.run(
202
+ core_publish.publish_to_channel(
203
+ endpoint.host,
204
+ endpoint.port,
205
+ channel,
206
+ text,
207
+ from_name,
208
+ tls=endpoint.tls,
209
+ data_dir=endpoint.data_dir,
210
+ tls_cert_path=endpoint.tls_cert_path,
211
+ )
212
+ )
213
+ except SystemExit as exc:
214
+ return _system_exit_code(exc)
215
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
216
+ return _expected_error_code(exc)
217
+ # Pi publish success prints the welcome envelope, matching send/broadcast.
218
+ if result.error is not None:
219
+ print(
220
+ f"inter-agent-pi: publish failed ({result.error.code}): {result.error.message}",
221
+ file=sys.stderr,
222
+ )
223
+ return 1
224
+ print(result.welcome)
225
+ return 0
226
+
227
+
228
+ def channels(as_json: bool = True) -> int:
229
+ try:
230
+ endpoint = resolve_endpoint(allow_discovery=True)
231
+ result = asyncio.run(
232
+ core_channels.list_channels(
233
+ endpoint.host,
234
+ endpoint.port,
235
+ tls=endpoint.tls,
236
+ data_dir=endpoint.data_dir,
237
+ tls_cert_path=endpoint.tls_cert_path,
238
+ )
239
+ )
240
+ except SystemExit as exc:
241
+ return _system_exit_code(exc)
242
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
243
+ return _expected_error_code(exc)
244
+ print(result.raw_response)
245
+ return 0 if result.response.get("op") == "channels_ok" else 1
246
+
247
+
248
+ def list_sessions() -> int:
249
+ try:
250
+ endpoint = resolve_endpoint(allow_discovery=True)
251
+ result = asyncio.run(
252
+ core_list.list_sessions(
253
+ endpoint.host,
254
+ endpoint.port,
255
+ tls=endpoint.tls,
256
+ data_dir=endpoint.data_dir,
257
+ tls_cert_path=endpoint.tls_cert_path,
258
+ )
259
+ )
260
+ except SystemExit as exc:
261
+ return _system_exit_code(exc)
262
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
263
+ return _expected_error_code(exc)
264
+ print(result.raw_response)
265
+ return 0
266
+
267
+
268
+ def kick(name: str) -> int:
269
+ """Force-disconnect a named agent role session through a control connection.
270
+
271
+ User-only: the host command surface invokes this; no model-callable tool
272
+ wraps it. It does not require the local Pi listener to be connected.
273
+ """
274
+ try:
275
+ endpoint = resolve_endpoint(allow_discovery=True)
276
+ result = asyncio.run(
277
+ core_kick.kick_session(
278
+ endpoint.host,
279
+ endpoint.port,
280
+ name=name,
281
+ tls=endpoint.tls,
282
+ data_dir=endpoint.data_dir,
283
+ tls_cert_path=endpoint.tls_cert_path,
284
+ )
285
+ )
286
+ except SystemExit as exc:
287
+ return _system_exit_code(exc)
288
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
289
+ return _expected_error_code(exc)
290
+ if result.response_payload.get("op") == "kick_ok":
291
+ print(result.response)
292
+ return 0
293
+ code = result.response_payload.get("code", "PROTOCOL_ERROR")
294
+ message = result.response_payload.get("message", "kick failed")
295
+ print(f"inter-agent-pi: ({code}): {message}", file=sys.stderr)
296
+ return 1
297
+
298
+
299
+ def shutdown() -> int:
300
+ try:
301
+ endpoint = resolve_endpoint(allow_discovery=True)
302
+ result = asyncio.run(
303
+ core_shutdown.shutdown_server(
304
+ endpoint.host,
305
+ endpoint.port,
306
+ tls=endpoint.tls,
307
+ data_dir=endpoint.data_dir,
308
+ tls_cert_path=endpoint.tls_cert_path,
309
+ )
310
+ )
311
+ except SystemExit as exc:
312
+ return _system_exit_code(exc)
313
+ except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
314
+ return _expected_error_code(exc)
315
+ print(result.response)
316
+ return 0 if result.response_payload.get("op") == "shutdown_ok" else 1
317
+
318
+
319
+ def status() -> dict[str, object]:
320
+ command = core_status.command_status()
321
+ endpoint = resolve_endpoint(allow_discovery=True)
322
+ server = asyncio.run(core_status.check_resolved_server_status(endpoint))
323
+ return {
324
+ "state": server.state,
325
+ "host": server.host,
326
+ "port": server.port,
327
+ "configured_host": server.configured_host,
328
+ "configured_port": server.configured_port,
329
+ "scheme": server.scheme,
330
+ "tls": server.tls,
331
+ "tls_source": server.tls_source,
332
+ "tls_cert_path": server.tls_cert_path,
333
+ "tls_cert_source": server.tls_cert_source,
334
+ "host_source": server.host_source,
335
+ "port_source": server.port_source,
336
+ "data_dir": server.data_dir,
337
+ "data_dir_source": server.data_dir_source,
338
+ "config_path": server.config_path,
339
+ "hints": list(server.hints),
340
+ "server_reachable": server.reachable,
341
+ "message": server.message,
342
+ "core_list_supported": command.list_supported,
343
+ "adapter_list_exposed": True,
344
+ }
345
+
346
+
347
+ def status_json() -> str:
348
+ return json.dumps(status())
@@ -0,0 +1,409 @@
1
+ """Long-running Pi listener with automatic reconnection.
2
+
3
+ The Pi extension spawns this listener as a child process. It connects to the
4
+ inter-agent bus as an agent session, prints server frames to stdout (one JSON
5
+ object per line), and reconnects with bounded backoff when the connection
6
+ drops. The server is auto-started if it is not running.
7
+
8
+ The extension reads stdout frames: ``welcome`` marks the listener ready,
9
+ ``msg`` frames are delivered as notifications, and ``error`` frames indicate
10
+ permanent connection rejections.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import os
20
+ import random
21
+ import socket
22
+ import subprocess
23
+ import sys
24
+ from collections.abc import Sequence
25
+ from pathlib import Path
26
+ from typing import TextIO
27
+
28
+ import websockets
29
+ from inter_agent.core import adapter_control as control
30
+ from inter_agent.core.client import AgentSession
31
+ from inter_agent.core.shared import DEFAULT_HOST, DEFAULT_PORT, resolve_endpoint
32
+
33
+ RECONNECT_BACKOFF_MIN_S = 0.5
34
+ RECONNECT_BACKOFF_MAX_S = 4.0
35
+ RECONNECT_JITTER_FRAC = 0.2
36
+ RECONNECT_DEADLINE_S = 60.0
37
+ AUTO_STARTED_SERVER_IDLE_TIMEOUT_S = 300
38
+
39
+ # Terminal kick signal: a post-welcome KICKED error ends this listener process
40
+ # without reconnecting. It is intentionally not part of _PERMANENT_ERROR_CODES
41
+ # (which classify the first welcome-time frame); KICKED arrives after welcome.
42
+ KICKED_ERROR_CODE = "KICKED"
43
+
44
+ # Server error codes that won't resolve by reconnecting.
45
+ _PERMANENT_ERROR_CODES = frozenset(
46
+ {
47
+ "AUTH_FAILED",
48
+ "BAD_LABEL",
49
+ "BAD_NAME",
50
+ "BAD_ROLE",
51
+ "BAD_SESSION",
52
+ "NAME_TAKEN",
53
+ "SESSION_TAKEN",
54
+ "TOO_MANY_CONNECTIONS",
55
+ }
56
+ )
57
+
58
+ log = logging.getLogger("inter-agent.pi.listener")
59
+
60
+
61
+ def pi_data_dir() -> Path:
62
+ """Return the Pi adapter data directory under the core data dir."""
63
+ from inter_agent.core.shared import data_dir as core_data_dir
64
+
65
+ path = core_data_dir() / "pi-sessions"
66
+ path.mkdir(parents=True, exist_ok=True)
67
+ try:
68
+ os.chmod(path, 0o700)
69
+ except OSError:
70
+ pass
71
+ return path
72
+
73
+
74
+ def _control_socket_path(host: str, port: int, name: str) -> Path:
75
+ return control.control_socket_path("pi", host, port, name, pi_data_dir())
76
+
77
+
78
+ class PermanentError(Exception):
79
+ """Raised when the server returns an error that reconnecting cannot resolve."""
80
+
81
+
82
+ def endpoint_available(host: str, port: int) -> bool:
83
+ """Return True when the configured TCP endpoint accepts connections."""
84
+ try:
85
+ with socket.create_connection((host, port), timeout=0.2):
86
+ return True
87
+ except OSError:
88
+ return False
89
+
90
+
91
+ def _print_frame(payload: str, output: TextIO) -> None:
92
+ """Emit a single JSON frame to stdout, flushed."""
93
+ output.write(payload + "\n")
94
+ output.flush()
95
+
96
+
97
+ def _start_server(
98
+ host: str,
99
+ port: int,
100
+ *,
101
+ tls: bool = False,
102
+ tls_cert_path: str | None = None,
103
+ tls_key_path: str | None = None,
104
+ ) -> subprocess.Popen[bytes] | None:
105
+ """Start inter-agent-server as a child process with an explicit idle timeout."""
106
+ try:
107
+ args = [
108
+ sys.executable,
109
+ "-m",
110
+ "inter_agent.core.server",
111
+ "--host",
112
+ host,
113
+ "--port",
114
+ str(port),
115
+ "--idle-timeout",
116
+ str(AUTO_STARTED_SERVER_IDLE_TIMEOUT_S),
117
+ ]
118
+ if tls:
119
+ args.append("--tls")
120
+ else:
121
+ args.append("--no-tls")
122
+ if tls_cert_path:
123
+ args.extend(["--tls-cert", tls_cert_path])
124
+ if tls_key_path:
125
+ args.extend(["--tls-key", tls_key_path])
126
+ return subprocess.Popen(
127
+ args,
128
+ stdout=subprocess.DEVNULL,
129
+ stderr=subprocess.DEVNULL,
130
+ )
131
+ except OSError:
132
+ return None
133
+
134
+
135
+ async def _connect_and_stream(
136
+ host: str,
137
+ port: int,
138
+ name: str,
139
+ label: str | None,
140
+ output: TextIO,
141
+ *,
142
+ tls: bool = False,
143
+ data_dir: Path | None = None,
144
+ tls_cert_path: Path | None = None,
145
+ desired_channels: set[str] | None = None,
146
+ control_path: Path | None = None,
147
+ ) -> None:
148
+ """Connect once, print raw frames, and return when the connection closes.
149
+
150
+ Reapplies the desired subscription set after a welcome so a transient
151
+ reconnect does not drop subscriptions. Raises PermanentError if the server
152
+ rejects the connection with a permanent error code as the first frame.
153
+ """
154
+ desired = desired_channels
155
+
156
+ async with AgentSession(
157
+ host, port, name, label, tls=tls, data_dir=data_dir, tls_cert_path=tls_cert_path
158
+ ) as session:
159
+ control_server: control.ControlServer | None = None
160
+
161
+ async def handle_request(op: str, channel: str) -> dict[str, object]:
162
+ try:
163
+ if op == "subscribe":
164
+ response = await session.subscribe(channel)
165
+ if response.get("op") == "subscribe_ok" and desired is not None:
166
+ desired.add(channel)
167
+ return response
168
+ response = await session.unsubscribe(channel)
169
+ if response.get("op") == "unsubscribe_ok" and desired is not None:
170
+ desired.discard(channel)
171
+ return response
172
+ except Exception as exc:
173
+ return {"op": "error", "code": "LISTENER_UNAVAILABLE", "message": str(exc)}
174
+
175
+ async def reapply_desired() -> None:
176
+ for channel in sorted(desired or ()):
177
+ try:
178
+ await session.subscribe(channel)
179
+ except Exception:
180
+ pass
181
+
182
+ async def bind_control() -> None:
183
+ # (Re)bind the local control socket. Failures fail closed: the
184
+ # listener stays usable and the extension is told control is
185
+ # unavailable rather than rediscovering the connection down.
186
+ nonlocal control_server
187
+ if control_server is not None:
188
+ await control_server.stop()
189
+ control_server = None
190
+ if control_path is None:
191
+ return
192
+ try:
193
+ server = control.ControlServer(control_path, handle_request)
194
+ started = await server.start()
195
+ if not started:
196
+ log.warning("control socket unavailable; subscribe/unsubscribe disabled")
197
+ await server.stop()
198
+ return
199
+ control_server = server
200
+ except OSError as exc:
201
+ log.warning("control socket setup failed: %s", exc)
202
+
203
+ try:
204
+ first = True
205
+ async for raw in session:
206
+ try:
207
+ payload = json.loads(raw)
208
+ except json.JSONDecodeError:
209
+ payload = None
210
+ op = payload.get("op") if payload is not None else None
211
+
212
+ if op == "welcome":
213
+ # Reapply subscriptions and bind the control socket BEFORE
214
+ # emitting the welcome so the host cannot mark the listener
215
+ # ready before channels are restored and the bridge is
216
+ # accepting requests.
217
+ await reapply_desired()
218
+ await bind_control()
219
+ _print_frame(raw, output)
220
+ first = False
221
+ continue
222
+
223
+ if first:
224
+ # The initial frame is part of readiness: emit it exactly
225
+ # once before acting on it so the host sees the raw error.
226
+ _print_frame(raw, output)
227
+ first = False
228
+ if op == "error":
229
+ code = payload.get("code", "") if payload is not None else ""
230
+ if isinstance(code, str) and code in _PERMANENT_ERROR_CODES:
231
+ raise PermanentError(f"{code}: {payload.get('message', '')}")
232
+ return
233
+ continue
234
+ if (
235
+ op == "error"
236
+ and payload is not None
237
+ and payload.get("code") == KICKED_ERROR_CODE
238
+ ):
239
+ # Terminal kick: stop reconnecting for this listener process.
240
+ # The routing name stays free for an explicit later reconnect.
241
+ raise PermanentError(f"{KICKED_ERROR_CODE}: {payload.get('message', '')}")
242
+ if (
243
+ op == "msg"
244
+ and isinstance(payload.get("channel"), str)
245
+ and payload.get("from_name") == name
246
+ ):
247
+ continue
248
+ _print_frame(raw, output)
249
+ finally:
250
+ if control_server is not None:
251
+ await control_server.stop()
252
+ control_server = None
253
+
254
+
255
+ def _jittered_delay(backoff: float) -> float:
256
+ jitter = backoff * RECONNECT_JITTER_FRAC
257
+ return max(0.0, backoff + random.uniform(-jitter, jitter))
258
+
259
+
260
+ async def run_listener(
261
+ host: str = DEFAULT_HOST,
262
+ port: int = DEFAULT_PORT,
263
+ name: str = "",
264
+ label: str | None = None,
265
+ output: TextIO | None = None,
266
+ deadline_s: float = RECONNECT_DEADLINE_S,
267
+ *,
268
+ tls: bool = False,
269
+ data_dir: Path | None = None,
270
+ tls_cert_path: Path | None = None,
271
+ tls_key_path: Path | None = None,
272
+ ) -> int:
273
+ """Run the listener with automatic reconnection and eventual give-up.
274
+
275
+ Reconnects with bounded backoff while the connection is down. Gives up if
276
+ a reconnection would land at or past ``deadline_s`` seconds from the first
277
+ failure. Each successful connection resets the deadline, so a flapping
278
+ server that connects intermittently does not give up.
279
+
280
+ Returns 0 on clean shutdown, 1 on permanent error or give-up.
281
+ """
282
+ stream = output or sys.stdout
283
+ desired_channels: set[str] = set()
284
+ try:
285
+ control_path: Path | None = _control_socket_path(host, port, name)
286
+ except OSError:
287
+ log.warning("control socket unavailable; subscribe/unsubscribe disabled")
288
+ control_path = None
289
+ backoff = RECONNECT_BACKOFF_MIN_S
290
+ deadline: float | None = None
291
+ server_started = False
292
+
293
+ while True:
294
+ # Ensure the server is running, auto-starting if needed.
295
+ if not endpoint_available(host, port):
296
+ if not server_started:
297
+ proc = _start_server(
298
+ host,
299
+ port,
300
+ tls=tls,
301
+ tls_cert_path=str(tls_cert_path) if tls_cert_path is not None else None,
302
+ tls_key_path=str(tls_key_path) if tls_key_path is not None else None,
303
+ )
304
+ if proc is None:
305
+ print(
306
+ "[inter-agent] failed to auto-start server; giving up",
307
+ file=sys.stderr,
308
+ )
309
+ return 1
310
+ server_started = True
311
+ log.info("auto-started server pid %s", proc.pid)
312
+ # Wait for the server to come up.
313
+ ready = False
314
+ for _ in range(30):
315
+ if endpoint_available(host, port):
316
+ ready = True
317
+ break
318
+ await asyncio.sleep(0.5)
319
+ if not ready:
320
+ if deadline is None:
321
+ deadline = asyncio.get_running_loop().time() + deadline_s
322
+ if asyncio.get_running_loop().time() >= deadline:
323
+ print(
324
+ "[inter-agent] server did not become available; giving up",
325
+ file=sys.stderr,
326
+ )
327
+ return 1
328
+ await asyncio.sleep(_jittered_delay(backoff))
329
+ backoff = min(backoff * 2, RECONNECT_BACKOFF_MAX_S)
330
+ continue
331
+
332
+ try:
333
+ await _connect_and_stream(
334
+ host,
335
+ port,
336
+ name,
337
+ label,
338
+ stream,
339
+ tls=tls,
340
+ data_dir=data_dir,
341
+ tls_cert_path=tls_cert_path,
342
+ desired_channels=desired_channels,
343
+ control_path=control_path,
344
+ )
345
+ # Connection closed normally — reset and reconnect.
346
+ backoff = RECONNECT_BACKOFF_MIN_S
347
+ deadline = None
348
+ except PermanentError:
349
+ return 1
350
+ except (ConnectionRefusedError, OSError, websockets.ConnectionClosed):
351
+ pass
352
+
353
+ if deadline is None:
354
+ deadline = asyncio.get_running_loop().time() + deadline_s
355
+ if asyncio.get_running_loop().time() >= deadline:
356
+ print(
357
+ f"[inter-agent] giving up; could not reconnect within {deadline_s:.0f}s",
358
+ file=sys.stderr,
359
+ )
360
+ return 1
361
+
362
+ await asyncio.sleep(_jittered_delay(backoff))
363
+ backoff = min(backoff * 2, RECONNECT_BACKOFF_MAX_S)
364
+
365
+
366
+ def build_parser() -> argparse.ArgumentParser:
367
+ parser = argparse.ArgumentParser(prog="inter-agent-pi connect")
368
+ parser.add_argument("name", nargs="?")
369
+ parser.add_argument("--name", dest="name_option")
370
+ parser.add_argument("--label")
371
+ parser.add_argument("--host")
372
+ parser.add_argument("--port", type=int)
373
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
374
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
375
+ parser.add_argument("--tls-cert")
376
+ parser.add_argument("--tls-key")
377
+ return parser
378
+
379
+
380
+ def main(argv: Sequence[str] | None = None) -> int:
381
+ parser = build_parser()
382
+ args = parser.parse_args(argv)
383
+ name = args.name_option or args.name
384
+ if not name:
385
+ parser.error("name is required")
386
+ endpoint = resolve_endpoint(
387
+ args.host,
388
+ args.port,
389
+ allow_discovery=True,
390
+ tls=args.tls,
391
+ tls_cert_path=args.tls_cert,
392
+ tls_key_path=args.tls_key,
393
+ )
394
+ return asyncio.run(
395
+ run_listener(
396
+ endpoint.host,
397
+ endpoint.port,
398
+ name,
399
+ args.label,
400
+ tls=endpoint.tls,
401
+ data_dir=endpoint.data_dir,
402
+ tls_cert_path=endpoint.tls_cert_path,
403
+ tls_key_path=endpoint.tls_key_path,
404
+ )
405
+ )
406
+
407
+
408
+ if __name__ == "__main__":
409
+ raise SystemExit(main())
@@ -0,0 +1,228 @@
1
+ Metadata-Version: 2.4
2
+ Name: inter-agent-pi
3
+ Version: 0.2.0
4
+ Summary: Pi helper for the inter-agent message bus: connect listener and command-line tools
5
+ Classifier: Development Status :: 3 - Alpha
6
+ Classifier: Intended Audience :: Developers
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Topic :: Communications
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE.md
15
+ Requires-Dist: inter-agent-core==0.2.0
16
+ Requires-Dist: websockets==16.0
17
+ Dynamic: license-file
18
+
19
+ # inter-agent-pi
20
+
21
+ [![Pi](https://img.shields.io/badge/pi-extension-purple)](https://github.com/arcanemachine/inter-agent-pi)
22
+
23
+ The Pi extension for the inter-agent message bus.
24
+
25
+ It connects a Pi coding-agent session to the bus as a named agent,
26
+ exposes grouped `/inter-agent` commands and a bounded set of agent-callable
27
+ tools, delivers incoming peer messages as Pi notifications, and ships a Python
28
+ helper (`inter-agent-pi` console command, `inter_agent_pi` import package) that
29
+ wraps the importable [`inter-agent-core`](#runtime-dependency) listener and
30
+ command APIs.
31
+
32
+ This repository is an independent clean-history child. It contains no former
33
+ monorepo history, no private workflow, no core runtime source, and no Claude
34
+ Code material.
35
+
36
+ ## Installation (Pi extension)
37
+
38
+ From a Pi session:
39
+
40
+ ```bash
41
+ pi install npm:inter-agent-pi@0.2.0
42
+ ```
43
+
44
+ Or install from a published Git tag (once published; do not pin a raw commit
45
+ hash):
46
+
47
+ ```bash
48
+ pi install https://github.com/arcanemachine/inter-agent-pi
49
+ ```
50
+
51
+ Or load directly from a source checkout during development:
52
+
53
+ ```bash
54
+ pi -e /path/to/inter-agent-pi/src/index.ts
55
+ ```
56
+
57
+ The extension entry point is `./src/index.ts`.
58
+
59
+ ## Runtime dependency
60
+
61
+ The helper depends on `inter-agent-core` `0.2.0` and on `websockets` `16.0`. A compatible `inter-agent-core` install
62
+ must provide the `inter-agent-pi`, `inter-agent-server`, `inter-agent-connect`,
63
+ `inter-agent-send`, `inter-agent-list`, `inter-agent-status`,
64
+ `inter-agent-shutdown`, `inter-agent-kick`, `inter-agent-publish`, and
65
+ `inter-agent-channels` console commands and the importable `inter_agent`
66
+ namespace, including the promoted `inter_agent.core.adapter_control` bridge.
67
+
68
+ > **Development note (non-release):** while the permanent `inter-agent-core`
69
+ > repository is being prepared, the Python helper may be resolved against a
70
+ > temporary local `inter-agent-core` candidate via a migration-only
71
+ > `[tool.uv.sources]` path entry. That path source is removed and the lock is
72
+ > re-resolved against the permanent `inter-agent-core` repository before any
73
+ > publication. Never publish while the temporary path source remains.
74
+
75
+ ### Helper resolution precedence
76
+
77
+ The extension resolves the Python runtime in this order:
78
+
79
+ 1. `INTER_AGENT_PI_HELPER` — an exact path to an `inter-agent-pi` executable;
80
+ its bin directory must also contain the required core helper scripts.
81
+ 2. An explicitly configured `interAgent.projectPath` — the helper is resolved
82
+ from that checkout's `.venv/bin`; if it is configured but incomplete the
83
+ extension fails fast with a bounded, actionable message.
84
+ 3. The extension-managed, documented runtime venv.
85
+ 4. `inter-agent-pi`, `inter-agent-connect`, and `inter-agent-server` discovered
86
+ together on `PATH`.
87
+ 5. A bounded setup-needed failure pointing back to this README.
88
+
89
+ The legacy implicit fallback to `~/.local/share/inter-agent` is intentionally
90
+ removed; that was a monorepo-era bootstrap assumption that no longer applies
91
+ to the standalone package. Explicitly configured `interAgent.projectPath`
92
+ remains supported for development.
93
+
94
+ ## Installing the Python helper
95
+
96
+ From a source checkout:
97
+
98
+ ```bash
99
+ uv sync --locked # resolve inter-agent-core + dev/test tooling
100
+ uv build # build wheel + sdist into dist/
101
+ ```
102
+
103
+ Install the built wheel into a venv that already provides a compatible
104
+ `inter-agent-core`, for example:
105
+
106
+ ```bash
107
+ uv venv .venv
108
+ uv pip install ./dist/*.whl <compatible-inter-agent-core-wheel>
109
+ ```
110
+
111
+ The `inter-agent-pi` console command and the agent-callable tools all reuse
112
+ this runtime.
113
+
114
+ ## Configuration
115
+
116
+ Configuration is read from the Pi agent settings file under `interAgent.*`.
117
+ Relative paths (`projectPath`, `dataDir`, TLS cert/key) resolve against the
118
+ directory of the settings file.
119
+
120
+ - `host` / `port` — override the default bus endpoint.
121
+ - `dataDir` — shared bus state directory; defaults to the core default.
122
+ - `secret` — shared secret for challenge-response auth; forwarded to helpers
123
+ as `INTER_AGENT_SECRET`. Do not store secrets in plaintext files in the repo.
124
+ - `tls` / `tlsCert` / `tlsKey` — enable explicit TLS and point at a
125
+ certificate/key pair.
126
+
127
+ Helper resolution precedence and the environment variables used by the helper
128
+ match [`inter-agent-core`](#runtime-dependency). Helper install path, runtime
129
+ state/config path, and bus state directories stay distinct so the bus identity
130
+ does not fragment across installs.
131
+
132
+ ## Commands
133
+
134
+ All commands ride the grouped `/inter-agent` command:
135
+
136
+ ```
137
+ usage: /inter-agent <connect|disconnect|kick|rename|send|broadcast|publish|channels|subscribe|unsubscribe|list|status|delivery> [args]
138
+ ```
139
+
140
+ - `/inter-agent connect <name> [--label <label>]` — connect this session as a
141
+ named agent; auto-starts the server if unavailable.
142
+ - `/inter-agent disconnect` — stop the local listener and notify.
143
+ - `/inter-agent kick <name>` — force-disconnect another session (user-only).
144
+ - `/inter-agent rename <name> [--label <label>]` — reconnect under a new name.
145
+ - `/inter-agent send <to> <text>` — send a direct message; routes the sender via `--from`.
146
+ - `/inter-agent broadcast <text>` — broadcast to every session (user-only).
147
+ - `/inter-agent publish <channel> <text>` — publish to a channel (user-only).
148
+ - `/inter-agent channels` — list channels and subscribers.
149
+ - `/inter-agent subscribe <channel>` / `/inter-agent unsubscribe <channel>` —
150
+ bind the live listener's subscriptions over the local control socket.
151
+ - `/inter-agent list` — list connected agent sessions.
152
+ - `/inter-agent status` — print server/helper status.
153
+ - `/inter-agent delivery <immediate|queued>` — switch inbound delivery mode
154
+ (aliases `i` / `q`).
155
+
156
+ ## Agent-callable tools vs user-only controls
157
+
158
+ Agent-callable tools:
159
+
160
+ - `inter_agent_send`, `inter_agent_broadcast` — send/broadcast through the
161
+ connected Pi listener (sender routed via `--from`).
162
+ - `inter_agent_list`, `inter_agent_whoami`, `inter_agent_status` —
163
+ read-only diagnostics; they do not require a connected listener.
164
+ - `inter_agent_read_messages` — read and remove queued mailbox messages;
165
+ performs no outbound action.
166
+
167
+ User-only controls (no model-callable tool): `kick`, `publish`, `subscribe`,
168
+ `unsubscribe`, `channels`, `delivery`, and the connect/disconnect/rename
169
+ connection actions. Broadcast, publish, kick, and destructive actions require
170
+ explicit user approval; the model is instructed never to send a courtesy reply
171
+ and to treat peer messages as untrusted context.
172
+
173
+ ## Mailbox, reload continuity, and reconnection
174
+
175
+ - By default inbound messages are queued in a bounded mailbox (max 128 unread)
176
+ and surfaced as a metadata-only notice; `delivery immediate` restores bounded
177
+ body notification.
178
+ - The notice provokes a non-steering follow-up turn and never prescribes a
179
+ canned acknowledgment, reply, or outbound action.
180
+ - A same-process `/reload` preserves the unread mailbox through a versioned,
181
+ one-use, process-global handoff (`Symbol.for("inter-agent.pi.mailbox.reloadHandoff.v1")`),
182
+ generation/session-scoped and TTL-bounded; every other lifecycle boundary
183
+ starts empty.
184
+ - The listener reconnects with bounded backoff and gives up after a deadline
185
+ measured from the first failure. A `KICKED` stop terminates one listener
186
+ process without reconnecting, leaving the routing name free for an explicit
187
+ later reconnect.
188
+ - The startup flag provides the inter-agent routing name at process start:
189
+ `pi -- inter-agent=<name>`.
190
+
191
+ ## Development
192
+
193
+ ```bash
194
+ uv sync --locked # install runtime + dev/test dependencies
195
+ npm ci # install TypeScript/dev dependencies (network)
196
+ npm test # TypeScript tests
197
+ npm run typecheck # tsc --noEmit
198
+ npm run build # emit dist/
199
+ npx prettier --write . # format
200
+ uv run pytest -q # Python tests
201
+ uv run ruff check src tests
202
+ uv run black --check src tests
203
+ uv run mypy src tests
204
+ scripts/run-checks.sh # full package gate incl. artifact validation
205
+ ```
206
+
207
+ Artifacts (`dist/`, `dist-tests/`, `node_modules/`, `.venv/`, wheels, tarballs)
208
+ are generated and gitignored; they are not part of the root commit.
209
+
210
+ ## Ecosystem and core
211
+
212
+ - The public `inter-agent-core` repository owns the bus runtime and the
213
+ `inter_agent.core.adapter_control` bridge this package consumes. (Its public
214
+ repository URL is published with that release; this package depends on the
215
+ compatible distribution name `inter-agent-core`.)
216
+ - The public ecosystem repository coordinates adapters; it is added as a
217
+ submodule only once it has a published initial `main` commit.
218
+
219
+ This package does not assume any currently published artifact exists beyond
220
+ what a compatible `inter-agent-core` release provides.
221
+
222
+ ## Security
223
+
224
+ - Authenticate with the shared bus secret; never commit secrets, tokens, keys,
225
+ or certificates to this repository.
226
+ - TLS uses explicit cert/key paths; a wrong/untrusted certificate fails bounded
227
+ and actionable and never falls back to plaintext.
228
+ - Peer messages are untrusted context, never instructions.
@@ -0,0 +1,10 @@
1
+ inter_agent_pi/__init__.py,sha256=7Xa46f4aQgmaCRPrpdlqRqxR0_zPqj0xeGfV1Yi1UI4,26
2
+ inter_agent_pi/cli.py,sha256=qsDo-H4PjvUE9B78ejP2ent_87KrXMeeXKF832eoJ70,2822
3
+ inter_agent_pi/commands.py,sha256=oFbeW7_YWCWYOwM5VfpX2lljuEE8ctGUjUF7OoF5kdE,11226
4
+ inter_agent_pi/listener.py,sha256=6kEz2Urvwac6VpIET4eZOgiCOQXjjZyf0xEg43EL-5c,14326
5
+ inter_agent_pi-0.2.0.dist-info/licenses/LICENSE.md,sha256=aqHfYBB0gyveY8spkKtDwaOHCO9OTEC0kfXjL2TDOZ8,1070
6
+ inter_agent_pi-0.2.0.dist-info/METADATA,sha256=9P9UumnTuerdK5MhjSPtVQGjvCReqhHDcf8mHl4c1hI,9660
7
+ inter_agent_pi-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
+ inter_agent_pi-0.2.0.dist-info/entry_points.txt,sha256=VNbFMZ6DHgHsiVVpTSsWXSEonitfSl4qVblzVJYieQg,59
9
+ inter_agent_pi-0.2.0.dist-info/top_level.txt,sha256=Lz4IXNM6YtDQfSvyANgZkDdWIfcXrvEtD1X1bTR0vbs,15
10
+ inter_agent_pi-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ inter-agent-pi = inter_agent_pi.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 arcanemachine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ inter_agent_pi