@agentsdock/server 1.0.7-beta.5

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 (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,1237 @@
1
+ #!/usr/bin/env python3
2
+ """Capability-scoped same-server chat contact CLI for AgentsDock agents."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import http.client
9
+ import ipaddress
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+ import time
15
+ import urllib.error
16
+ import urllib.parse
17
+ import urllib.request
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ # The legacy ``response_timeout_seconds`` wire field is now only a requested
22
+ # heartbeat interval. There is deliberately no client response-deadline
23
+ # constant. A provider tool call observes at most one bounded slice, then
24
+ # returns either the server's explicit pending receipt or an honest retryable
25
+ # transport receipt. The provider immediately invokes ``wait`` with those
26
+ # exact opaque IDs until the server lease reaches terminal state. Keeping
27
+ # every network observation at 30 seconds or less bounds the whole idempotent
28
+ # command safely below provider shell caps, instead of turning any
29
+ # provider-specific Bash limit into a cross-chat response deadline.
30
+ LIVE_RESPONSE_HEARTBEAT_SECONDS = 20
31
+ LIVE_RESPONSE_MAX_HEARTBEAT_SECONDS = 20
32
+ LIVE_RESPONSE_SOCKET_GRACE_SECONDS = 10
33
+ LIVE_RESPONSE_POST_SOCKET_SECONDS = 10
34
+ IDEMPOTENT_POST_RETRY_DELAYS_SECONDS = (0.1, 0.5)
35
+ IDEMPOTENT_GET_RETRY_DELAYS_SECONDS = (0.1, 0.5)
36
+ PROVIDER_RUNTIME_VALUE_MAX_BYTES = 4096
37
+ PROVIDER_RUNTIME_HANDLE_MAX_COUNT = 64
38
+ MESSAGE_STDIN_MAX_CHARS = 100_000
39
+ MESSAGE_STDIN_MAX_BYTES = 400 * 1024
40
+
41
+
42
+ class ChatsCLIError(RuntimeError):
43
+ pass
44
+
45
+
46
+ class LiveWaitRetryable(ChatsCLIError):
47
+ """One bounded live-wait slice lost transport, but its lease is intact."""
48
+
49
+
50
+ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
51
+ def redirect_request(self, *_args: Any, **_kwargs: Any) -> None:
52
+ return None
53
+
54
+
55
+ def host_is_loopback(host: str) -> bool:
56
+ try:
57
+ address = ipaddress.ip_address(host)
58
+ except ValueError:
59
+ return host.lower() == "localhost"
60
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
61
+ address = address.ipv4_mapped
62
+ return address.is_loopback
63
+
64
+
65
+ def _canonical_http_origin(value: str, label: str) -> tuple[str, bool]:
66
+ raw = value.strip()
67
+ try:
68
+ parsed = urllib.parse.urlsplit(raw)
69
+ port = parsed.port or 80
70
+ except ValueError as exc:
71
+ raise ChatsCLIError(f"{label} must be an HTTP origin") from exc
72
+ if (
73
+ parsed.scheme.lower() != "http"
74
+ or not parsed.hostname
75
+ or parsed.username is not None
76
+ or parsed.password is not None
77
+ or parsed.path not in {"", "/"}
78
+ or parsed.query
79
+ or parsed.fragment
80
+ ):
81
+ raise ChatsCLIError(f"{label} must be an HTTP origin")
82
+ host = parsed.hostname.lower()
83
+ try:
84
+ address = ipaddress.ip_address(host)
85
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
86
+ address = address.ipv4_mapped
87
+ host = address.compressed
88
+ loopback = address.is_loopback
89
+ url_host = f"[{host}]" if isinstance(address, ipaddress.IPv6Address) else host
90
+ except ValueError:
91
+ loopback = host == "localhost"
92
+ url_host = host
93
+ return f"http://{url_host}:{port}", loopback
94
+
95
+
96
+ def _authority_server_origin(path: str | None) -> str:
97
+ authority_path = _authority_path(path)
98
+ try:
99
+ if authority_path.stat().st_mode & 0o077:
100
+ raise ChatsCLIError("authority file permissions are unsafe")
101
+ payload = json.loads(authority_path.read_text(encoding="utf-8"))
102
+ except (OSError, json.JSONDecodeError) as exc:
103
+ raise ChatsCLIError(f"could not read authority file: {exc}") from exc
104
+ return _bounded_identity_value(
105
+ payload.get("provider_server_origin"),
106
+ "authority provider_server_origin",
107
+ )
108
+
109
+
110
+ def environment() -> str:
111
+ raw_server_url = os.environ.get("AGENTSDOCK_SERVER_URL", "").strip()
112
+ if not raw_server_url:
113
+ raise ChatsCLIError("missing AgentsDock agent environment")
114
+ server_origin, loopback = _canonical_http_origin(
115
+ raw_server_url,
116
+ "AGENTSDOCK_SERVER_URL",
117
+ )
118
+ runtime_origin = _bounded_runtime_value(
119
+ "AGENTSDOCK_PROVIDER_SERVER_ORIGIN"
120
+ )
121
+ if runtime_origin:
122
+ canonical_runtime, _runtime_loopback = _canonical_http_origin(
123
+ runtime_origin,
124
+ "AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
125
+ )
126
+ if canonical_runtime != server_origin:
127
+ raise ChatsCLIError(
128
+ "AGENTSDOCK_SERVER_URL conflicts with the live provider origin"
129
+ )
130
+ if loopback:
131
+ return raw_server_url.rstrip("/")
132
+ authority_origin = _authority_server_origin(None)
133
+ if not authority_origin:
134
+ raise ChatsCLIError(
135
+ "non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
136
+ )
137
+ canonical_authority, _authority_loopback = _canonical_http_origin(
138
+ authority_origin,
139
+ "authority provider_server_origin",
140
+ )
141
+ if canonical_authority != server_origin:
142
+ raise ChatsCLIError(
143
+ "non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
144
+ )
145
+ return server_origin
146
+
147
+
148
+ def _bounded_identity_value(value: str | None, label: str) -> str:
149
+ clean = str(value or "").strip()
150
+ try:
151
+ encoded_size = len(clean.encode("utf-8"))
152
+ except UnicodeEncodeError as exc:
153
+ raise ChatsCLIError(f"{label} is not valid UTF-8") from exc
154
+ if encoded_size > PROVIDER_RUNTIME_VALUE_MAX_BYTES:
155
+ raise ChatsCLIError(f"{label} exceeds the provider runtime limit")
156
+ return clean
157
+
158
+
159
+ def _bounded_runtime_value(name: str) -> str:
160
+ return _bounded_identity_value(os.environ.get(name), name)
161
+
162
+
163
+ def _authority_path(path: str | None) -> Path:
164
+ explicit = _bounded_identity_value(path, "--authority-file")
165
+ ambient = _bounded_runtime_value("AGENTSDOCK_PROVIDER_AUTHORITY_FILE")
166
+ if explicit and ambient:
167
+ explicit_key = os.path.abspath(os.path.expanduser(explicit))
168
+ ambient_key = os.path.abspath(os.path.expanduser(ambient))
169
+ if explicit_key != ambient_key:
170
+ raise ChatsCLIError(
171
+ "--authority-file conflicts with the live provider authority"
172
+ )
173
+ selected = explicit or ambient
174
+ if not selected:
175
+ raise ChatsCLIError("--authority-file is required")
176
+ return Path(selected).expanduser()
177
+
178
+
179
+ def authority(path: str | None) -> str:
180
+ authority_path = _authority_path(path)
181
+ try:
182
+ mode = authority_path.stat().st_mode & 0o777
183
+ if mode & 0o077:
184
+ raise ChatsCLIError("authority file permissions are unsafe")
185
+ payload = json.loads(authority_path.read_text(encoding="utf-8"))
186
+ except (OSError, json.JSONDecodeError) as exc:
187
+ raise ChatsCLIError(f"could not read authority file: {exc}") from exc
188
+ token = str(payload.get("provider_capability") or payload.get("capability") or "")
189
+ source_session_id = str(payload.get("source_session_id") or "").strip()
190
+ if not token or not source_session_id:
191
+ raise ChatsCLIError("authority file is invalid")
192
+ environment_chat_id = _bounded_runtime_value("AGENTSDOCK_CHAT_ID")
193
+ if environment_chat_id and environment_chat_id != source_session_id:
194
+ raise ChatsCLIError(
195
+ "AGENTSDOCK_CHAT_ID does not match the authority file"
196
+ )
197
+ return token
198
+
199
+
200
+ def positive_target_index(value: str) -> int:
201
+ if len(value) > 2 or re.fullmatch(r"[1-9][0-9]*", value) is None:
202
+ raise argparse.ArgumentTypeError("--target-index must be a positive integer")
203
+ index = int(value)
204
+ if index > PROVIDER_RUNTIME_HANDLE_MAX_COUNT:
205
+ raise argparse.ArgumentTypeError(
206
+ f"--target-index must be at most {PROVIDER_RUNTIME_HANDLE_MAX_COUNT}"
207
+ )
208
+ return index
209
+
210
+
211
+ def provider_handle(index: int, action: str) -> tuple[str, bool]:
212
+ count_text = _bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_HANDLE_COUNT")
213
+ if re.fullmatch(r"0|[1-9][0-9]*", count_text) is None:
214
+ raise ChatsCLIError("the live @Chat handle count is unavailable")
215
+ count = int(count_text)
216
+ if count > PROVIDER_RUNTIME_HANDLE_MAX_COUNT or index > count:
217
+ raise ChatsCLIError("the requested @Chat handle is unavailable")
218
+ prefix = f"AGENTSDOCK_CROSS_CHAT_HANDLE_{index}"
219
+ handle = _bounded_runtime_value(prefix)
220
+ granted_action = _bounded_runtime_value(f"{prefix}_ACTION")
221
+ async_text = _bounded_runtime_value(f"{prefix}_ASYNC")
222
+ expected_action = "instruction" if action == "instruction" else "request_reply"
223
+ if not handle or granted_action != expected_action or async_text not in {"0", "1"}:
224
+ raise ChatsCLIError("the requested @Chat handle is unavailable")
225
+ if action == "instruction" and async_text != "0":
226
+ raise ChatsCLIError("the requested @Chat handle is malformed")
227
+ return handle, async_text == "1"
228
+
229
+
230
+ def respond_current(args: argparse.Namespace) -> dict[str, Any]:
231
+ if _bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_RESPONSE_MODE") == "async_route_v1":
232
+ route_id = _bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_RESPONSE_ROUTE_ID")
233
+ if re.fullmatch(r"route_[0-9a-f]{32}", route_id) is None:
234
+ raise ChatsCLIError("the current inbound conversation route is unavailable")
235
+ values = vars(args).copy()
236
+ values.update({"route": route_id, "target": None, "target_index": None,
237
+ "mode": "async_route_v1", "async_response": True})
238
+ return send_action(argparse.Namespace(**values), "instruction")
239
+ exchange_id = _bounded_runtime_value(
240
+ "AGENTSDOCK_CROSS_CHAT_RESPONSE_EXCHANGE_ID"
241
+ )
242
+ inbound_leg_id = _bounded_runtime_value(
243
+ "AGENTSDOCK_CROSS_CHAT_RESPONSE_INBOUND_LEG_ID"
244
+ )
245
+ followup = _bounded_runtime_value(
246
+ "AGENTSDOCK_CROSS_CHAT_RESPONSE_FOLLOWUP"
247
+ ) or "none"
248
+ if (
249
+ re.fullmatch(r"exchange_[0-9a-f]{32}", exchange_id) is None
250
+ or re.fullmatch(r"leg_[0-9a-f]{32}", inbound_leg_id) is None
251
+ or followup not in {"none", "allowed", "allowed-async"}
252
+ ):
253
+ raise ChatsCLIError("the current inbound reply grant is unavailable")
254
+ request_response = bool(args.request_response)
255
+ if request_response and followup == "none":
256
+ raise ChatsCLIError("the current inbound reply has no follow-up grant")
257
+ values = vars(args).copy()
258
+ values.update({
259
+ "exchange": exchange_id,
260
+ "inbound_leg": inbound_leg_id,
261
+ "async_response": request_response and followup == "allowed-async",
262
+ })
263
+ return respond(argparse.Namespace(**values))
264
+
265
+
266
+ def provider_headers(capability: str) -> dict[str, str]:
267
+ """Return the one canonical header accepted by agent-helper routes.
268
+
269
+ The retired cross-chat-specific header is intentionally omitted. The
270
+ server rejects requests that mix legacy and current authority names so a
271
+ browser or stale helper cannot smuggle ambiguous credentials.
272
+ """
273
+
274
+ return {
275
+ "Accept": "application/json",
276
+ "X-AgentsDock-Provider-Capability": capability,
277
+ }
278
+
279
+
280
+ def post_json(
281
+ path: str,
282
+ payload: dict[str, Any],
283
+ capability: str,
284
+ ) -> dict[str, Any]:
285
+ server_url = environment()
286
+ body = json.dumps(payload).encode("utf-8")
287
+ headers = {
288
+ **provider_headers(capability),
289
+ "Content-Type": "application/json",
290
+ }
291
+ request = urllib.request.Request(
292
+ f"{server_url}{path}",
293
+ data=body,
294
+ headers=headers,
295
+ method="POST",
296
+ )
297
+ opener = urllib.request.build_opener(
298
+ urllib.request.ProxyHandler({}),
299
+ NoRedirectHandler(),
300
+ )
301
+ promotion_deadline = time.monotonic() + 10.0
302
+ transport_retry = 0
303
+ while True:
304
+ try:
305
+ # The POST commits and returns a lease; response waiting happens
306
+ # through bounded GET heartbeats below.
307
+ socket_timeout = LIVE_RESPONSE_POST_SOCKET_SECONDS
308
+ with opener.open(request, timeout=socket_timeout) as response:
309
+ result = json.loads(response.read().decode("utf-8"))
310
+ break
311
+ except urllib.error.HTTPError as exc:
312
+ try:
313
+ raw = exc.read().decode("utf-8", errors="replace")
314
+ except (OSError, http.client.IncompleteRead) as read_exc:
315
+ retryable = bool(payload.get("idempotency_key"))
316
+ if (
317
+ retryable
318
+ and transport_retry
319
+ < len(IDEMPOTENT_POST_RETRY_DELAYS_SECONDS)
320
+ ):
321
+ delay = IDEMPOTENT_POST_RETRY_DELAYS_SECONDS[
322
+ transport_retry
323
+ ]
324
+ transport_retry += 1
325
+ time.sleep(delay)
326
+ continue
327
+ raise ChatsCLIError(
328
+ "could not confirm whether AgentsServer accepted the "
329
+ "request because its error response was truncated; do "
330
+ "not resend it with different wording"
331
+ ) from read_exc
332
+ try:
333
+ detail = json.loads(raw).get("detail") or raw
334
+ except json.JSONDecodeError:
335
+ detail = raw
336
+ if (
337
+ exc.code == 409
338
+ and detail == "agent chat access is waiting for turn promotion"
339
+ and time.monotonic() < promotion_deadline
340
+ ):
341
+ # The body and idempotency key are identical on every attempt.
342
+ # Promotion has made no durable target effect yet.
343
+ time.sleep(0.05)
344
+ continue
345
+ raise ChatsCLIError(
346
+ f"server rejected handoff ({exc.code}): {detail or exc.reason}"
347
+ ) from exc
348
+ except (
349
+ urllib.error.URLError,
350
+ TimeoutError,
351
+ OSError,
352
+ http.client.IncompleteRead,
353
+ UnicodeDecodeError,
354
+ json.JSONDecodeError,
355
+ ) as exc:
356
+ retryable = bool(payload.get("idempotency_key"))
357
+ if (
358
+ retryable
359
+ and transport_retry < len(IDEMPOTENT_POST_RETRY_DELAYS_SECONDS)
360
+ ):
361
+ delay = IDEMPOTENT_POST_RETRY_DELAYS_SECONDS[transport_retry]
362
+ transport_retry += 1
363
+ # Reuse the byte-identical request and idempotency key. The
364
+ # prior server attempt may still commit after its socket dies.
365
+ time.sleep(delay)
366
+ continue
367
+ detail = getattr(exc, "reason", exc)
368
+ if retryable:
369
+ raise ChatsCLIError(
370
+ "could not confirm whether AgentsServer accepted the "
371
+ "request after retrying the same idempotency key; do not "
372
+ f"resend it with different wording: {detail}"
373
+ ) from exc
374
+ raise ChatsCLIError(
375
+ f"could not reach AgentsServer: {detail}"
376
+ ) from exc
377
+ if not isinstance(result, dict):
378
+ raise ChatsCLIError("AgentsServer returned an invalid response")
379
+ return result
380
+
381
+
382
+ def get_json(
383
+ path: str,
384
+ capability: str,
385
+ *,
386
+ timeout: float = 30,
387
+ live_slice: bool = False,
388
+ ) -> dict[str, Any]:
389
+ server_url = environment()
390
+ request = urllib.request.Request(
391
+ f"{server_url}{path}",
392
+ headers=provider_headers(capability),
393
+ method="GET",
394
+ )
395
+ opener = urllib.request.build_opener(
396
+ urllib.request.ProxyHandler({}),
397
+ NoRedirectHandler(),
398
+ )
399
+ transport_retry = 0
400
+
401
+ def retry_delay() -> float:
402
+ return IDEMPOTENT_GET_RETRY_DELAYS_SECONDS[transport_retry]
403
+
404
+ while True:
405
+ try:
406
+ with opener.open(request, timeout=timeout) as response:
407
+ result = json.loads(response.read().decode("utf-8"))
408
+ break
409
+ except urllib.error.HTTPError as exc:
410
+ try:
411
+ raw = exc.read().decode("utf-8", errors="replace")
412
+ except (OSError, http.client.IncompleteRead) as read_exc:
413
+ if live_slice:
414
+ raise LiveWaitRetryable(
415
+ "the live-response transport was interrupted"
416
+ ) from read_exc
417
+ if (
418
+ transport_retry < len(IDEMPOTENT_GET_RETRY_DELAYS_SECONDS)
419
+ ):
420
+ delay = retry_delay()
421
+ transport_retry += 1
422
+ time.sleep(delay)
423
+ continue
424
+ raise ChatsCLIError(
425
+ "AgentsServer returned a truncated error response after "
426
+ "retrying the exact live-response lease"
427
+ ) from read_exc
428
+ try:
429
+ detail = json.loads(raw).get("detail") or raw
430
+ except json.JSONDecodeError:
431
+ detail = raw
432
+ if live_slice and (
433
+ exc.code in {408, 425, 429, 499}
434
+ or 500 <= exc.code <= 599
435
+ ):
436
+ raise LiveWaitRetryable(
437
+ "the live-response transport is temporarily unavailable"
438
+ ) from exc
439
+ raise ChatsCLIError(
440
+ f"server rejected request ({exc.code}): {detail or exc.reason}"
441
+ ) from exc
442
+ except (
443
+ urllib.error.URLError,
444
+ TimeoutError,
445
+ OSError,
446
+ http.client.IncompleteRead,
447
+ ) as exc:
448
+ if live_slice:
449
+ raise LiveWaitRetryable(
450
+ "the live-response transport is temporarily unavailable"
451
+ ) from exc
452
+ if (
453
+ transport_retry < len(IDEMPOTENT_GET_RETRY_DELAYS_SECONDS)
454
+ ):
455
+ delay = retry_delay()
456
+ transport_retry += 1
457
+ # GET is side-effect free and the live-response URL contains
458
+ # the same exact lease on every attempt. The server retains
459
+ # the result for this exact live provider-run owner.
460
+ time.sleep(delay)
461
+ continue
462
+ raise ChatsCLIError(
463
+ f"could not reach AgentsServer: {getattr(exc, 'reason', exc)}"
464
+ ) from exc
465
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
466
+ # A malformed HTTP success body is a protocol error, not evidence
467
+ # that a busy peer still has work queued. A live slice must also
468
+ # remain one bounded provider-tool observation, so fail it
469
+ # immediately instead of multiplying its socket-timeout budget.
470
+ if live_slice:
471
+ raise ChatsCLIError(
472
+ "AgentsServer returned an invalid live-response body"
473
+ ) from exc
474
+ # Other side-effect-free GETs retain the small ambiguity retry
475
+ # window, then fail instead of spinning forever on a corrupt or
476
+ # incompatible server.
477
+ if transport_retry < len(IDEMPOTENT_GET_RETRY_DELAYS_SECONDS):
478
+ delay = retry_delay()
479
+ transport_retry += 1
480
+ time.sleep(delay)
481
+ continue
482
+ raise ChatsCLIError(
483
+ "AgentsServer returned an invalid live-response body"
484
+ ) from exc
485
+ if not isinstance(result, dict):
486
+ raise ChatsCLIError("AgentsServer returned an invalid response")
487
+ return result
488
+
489
+
490
+ def live_response_heartbeat_seconds(value: int) -> int:
491
+ return max(1, min(int(value), LIVE_RESPONSE_MAX_HEARTBEAT_SECONDS))
492
+
493
+
494
+ def await_live_response(
495
+ receipt: dict[str, Any],
496
+ capability: str,
497
+ timeout_seconds: int,
498
+ ) -> dict[str, Any]:
499
+ exchange_id = str(receipt.get("exchange_id") or "")
500
+ inbound_leg_id = str(receipt.get("inbound_leg_id") or "")
501
+ lease_id = str(receipt.get("live_response_lease_id") or "")
502
+ if not re.fullmatch(r"exchange_[0-9a-f]{32}", exchange_id):
503
+ raise ChatsCLIError("live response exchange id is invalid")
504
+ if not re.fullmatch(r"leg_[0-9a-f]{32}", inbound_leg_id):
505
+ raise ChatsCLIError("live response inbound leg id is invalid")
506
+ if not re.fullmatch(r"lease_[0-9a-f]{32}", lease_id):
507
+ raise ChatsCLIError("live response lease id is invalid")
508
+ heartbeat_seconds = live_response_heartbeat_seconds(timeout_seconds)
509
+ query = urllib.parse.urlencode({
510
+ "lease_id": lease_id,
511
+ # Compatibility query name: the server treats it as a bounded
512
+ # transport heartbeat, never a total response deadline.
513
+ "timeout_seconds": heartbeat_seconds,
514
+ })
515
+ path = (
516
+ "/api/agent/cross-chat/exchanges/"
517
+ f"{urllib.parse.quote(exchange_id, safe='')}/legs/"
518
+ f"{urllib.parse.quote(inbound_leg_id, safe='')}/live-response?{query}"
519
+ )
520
+ answer_keys = {
521
+ "ok", "exchange_id", "inbound_leg_id", "body", "request_response",
522
+ }
523
+ deferred_keys = {
524
+ "ok", "exchange_id", "inbound_leg_id", "deferred", "delivery",
525
+ "message",
526
+ }
527
+ pending_keys = {"ok", "exchange_id", "inbound_leg_id", "pending"}
528
+ try:
529
+ result = get_json(
530
+ path,
531
+ capability,
532
+ timeout=(
533
+ heartbeat_seconds
534
+ + LIVE_RESPONSE_SOCKET_GRACE_SECONDS
535
+ ),
536
+ live_slice=True,
537
+ )
538
+ except LiveWaitRetryable:
539
+ # A transport failure says nothing about durable server state. In
540
+ # particular, the response may already be committed while the HTTP
541
+ # handler is still disconnecting. Never relabel that ambiguity as a
542
+ # genuine server-owned pending exchange. Return the same exact lease
543
+ # as a distinct retry receipt; replaying its GET is side-effect free
544
+ # and can recover a committed answer without resending the ask.
545
+ return {
546
+ "ok": False,
547
+ "exchange_id": exchange_id,
548
+ "inbound_leg_id": inbound_leg_id,
549
+ "live_response_lease_id": lease_id,
550
+ "transport_error": True,
551
+ "retryable": True,
552
+ "message": (
553
+ "AgentsServer did not confirm the live-response state because "
554
+ "the transport was interrupted. Retry the existing wait "
555
+ f"exactly with --exchange {exchange_id} "
556
+ f"--inbound-leg {inbound_leg_id} --lease {lease_id}; "
557
+ "do not resend the ask or change its wording."
558
+ ),
559
+ }
560
+ valid_answer = (
561
+ set(result) == answer_keys
562
+ and isinstance(result.get("body"), str)
563
+ and isinstance(result.get("request_response"), bool)
564
+ )
565
+ valid_deferred = (
566
+ set(result) == deferred_keys
567
+ and result.get("deferred") is True
568
+ and result.get("delivery") == "asynchronous"
569
+ and isinstance(result.get("message"), str)
570
+ )
571
+ valid_pending = (
572
+ set(result) == pending_keys
573
+ and result.get("pending") is True
574
+ and result.get("inbound_leg_id") == inbound_leg_id
575
+ )
576
+ if (
577
+ not (valid_answer or valid_deferred or valid_pending)
578
+ or result.get("ok") is not True
579
+ or result.get("exchange_id") != exchange_id
580
+ or not isinstance(result.get("inbound_leg_id"), str)
581
+ ):
582
+ raise ChatsCLIError("AgentsServer returned an invalid live response")
583
+ if valid_pending:
584
+ return {**result, "live_response_lease_id": lease_id}
585
+ return result
586
+
587
+
588
+ def wait(args: argparse.Namespace) -> dict[str, Any]:
589
+ """Observe one bounded slice of an already-committed live exchange."""
590
+
591
+ capability = authority(args.authority_file)
592
+ return await_live_response(
593
+ {
594
+ "exchange_id": args.exchange,
595
+ "inbound_leg_id": args.inbound_leg,
596
+ "live_response_lease_id": args.lease,
597
+ },
598
+ capability,
599
+ int(getattr(args, "timeout_seconds", LIVE_RESPONSE_HEARTBEAT_SECONDS)),
600
+ )
601
+
602
+
603
+ def list_routes(args: argparse.Namespace) -> dict[str, Any]:
604
+ capability = authority(args.authority_file)
605
+ cursor = str(getattr(args, "cursor", None) or "")
606
+ if cursor and re.fullmatch(r"route_[0-9a-f]{32}", cursor) is None:
607
+ raise ChatsCLIError("--cursor must be the previous route page's next_cursor")
608
+ path = "/api/agent/cross-chat/routes"
609
+ if cursor:
610
+ path += "?" + urllib.parse.urlencode({"cursor": cursor})
611
+ result = get_json(path, capability)
612
+ routes = result.get("routes")
613
+ if not isinstance(routes, list) or any(
614
+ not isinstance(route, dict) for route in routes
615
+ ):
616
+ raise ChatsCLIError("AgentsServer returned an invalid route list")
617
+ next_cursor = result.get("next_cursor")
618
+ if next_cursor is not None and (
619
+ not isinstance(next_cursor, str)
620
+ or re.fullmatch(r"route_[0-9a-f]{32}", next_cursor) is None
621
+ or next_cursor == cursor
622
+ ):
623
+ raise ChatsCLIError("AgentsServer returned an invalid route cursor")
624
+ if cursor and "next_cursor" not in result:
625
+ raise ChatsCLIError("this AgentsServer does not support paginated route discovery")
626
+ return result
627
+
628
+
629
+ def inbox(args: argparse.Namespace) -> dict[str, Any]:
630
+ """List body-free pending senders; never claim or start their messages."""
631
+ capability = authority(args.authority_file)
632
+ path = "/api/agent/cross-chat/inbox"
633
+ cursor = getattr(args, "cursor", None)
634
+ if cursor is not None:
635
+ if not isinstance(cursor, str) or not 1 <= len(cursor) <= 128:
636
+ raise ChatsCLIError("--cursor must be the previous inbox page's next_cursor")
637
+ path += "?" + urllib.parse.urlencode({"cursor": cursor})
638
+ return get_json(path, capability)
639
+
640
+
641
+ def read_inbox(args: argparse.Namespace) -> dict[str, Any]:
642
+ """Explicitly claim one sender page using a caller-stable durable receipt."""
643
+ sender = str(args.sender or "").strip()
644
+ request_id = str(args.request_id or "").strip()
645
+ if not 1 <= len(sender) <= 128:
646
+ raise ChatsCLIError("--sender must contain 1 to 128 characters")
647
+ if not 8 <= len(request_id) <= 128:
648
+ raise ChatsCLIError("--request-id must contain 8 to 128 characters and be reused for retries")
649
+ capability = authority(args.authority_file)
650
+ return post_json("/api/agent/cross-chat/inbox/read", {
651
+ "source_session_id": sender, "request_id": request_id,
652
+ "after_seq": args.cursor, "limit": args.limit,
653
+ }, capability)
654
+
655
+
656
+ def inbox_sequence(value: str) -> int:
657
+ if len(value) > 19 or re.fullmatch(r"0|[1-9][0-9]*", value) is None or int(value) > 2**63 - 1:
658
+ raise argparse.ArgumentTypeError("--cursor must be a nonnegative sequence from the previous response")
659
+ return int(value)
660
+
661
+
662
+ def negotiated_route_mode(capability: str, route_id: str, requested: str = "") -> str:
663
+ """Discover mode through a read before sending any state-changing request."""
664
+
665
+ # Older servers ignore this additive query and return their complete route
666
+ # list; keep exact filtering for both contracts. New servers return only
667
+ # the requested live route, including routes beyond the first list page.
668
+ response = get_json(
669
+ "/api/agent/cross-chat/routes?" + urllib.parse.urlencode({"route_id": route_id}),
670
+ capability,
671
+ )
672
+ routes = response.get("routes")
673
+ if not isinstance(routes, list):
674
+ raise ChatsCLIError("AgentsServer returned an invalid route list")
675
+ matches = [route for route in routes if isinstance(route, dict)
676
+ and route.get("route_id") == route_id]
677
+ if len(matches) != 1 or matches[0].get("available") is not True:
678
+ raise ChatsCLIError("the requested route is unavailable")
679
+ mode = str(matches[0].get("mode") or "")
680
+ if mode not in {"", "async_route_v1"} or (requested and mode != requested):
681
+ raise ChatsCLIError("AgentsServer did not negotiate the requested conversation mode")
682
+ return mode
683
+
684
+
685
+ def send_action(args: argparse.Namespace, action: str) -> dict[str, Any]:
686
+ capability = authority(args.authority_file)
687
+ message = str(args.message or "").strip()
688
+ if not message:
689
+ raise ChatsCLIError("--message must not be empty")
690
+ route = str(getattr(args, "route", None) or "")
691
+ target = str(getattr(args, "target", None) or "")
692
+ target_index = getattr(args, "target_index", None)
693
+ if sum((bool(route), bool(target), target_index is not None)) != 1:
694
+ raise ChatsCLIError(
695
+ "provide exactly one of --route, --target, or --target-index"
696
+ )
697
+ if target_index is not None:
698
+ if bool(getattr(args, "async_response", False)):
699
+ raise ChatsCLIError(
700
+ "--async-response is selected by the live @Chat grant"
701
+ )
702
+ target, grant_is_async = provider_handle(int(target_index), action)
703
+ if action == "request_reply":
704
+ args.async_response = grant_is_async
705
+ destination = route if route else target
706
+ requested_mode = str(getattr(args, "mode", None) or "")
707
+ reply_to = str(getattr(args, "reply_to", None) or "").strip()
708
+ if reply_to and (not route or re.fullmatch(r"handoff_[0-9a-f]{32}", reply_to) is None):
709
+ raise ChatsCLIError("--reply-to requires an exact asynchronous route and message ID from the inbox")
710
+ if requested_mode and not route:
711
+ raise ChatsCLIError("conversation mode requires an exact route")
712
+ discover_mode = bool(requested_mode or reply_to) or (
713
+ _bounded_runtime_value("AGENTSDOCK_CROSS_CHAT_MODE") == "async_route_v1"
714
+ )
715
+ mode = negotiated_route_mode(capability, route, requested_mode) if route and discover_mode else ""
716
+ if reply_to and mode != "async_route_v1":
717
+ raise ChatsCLIError("--reply-to is not supported by legacy exchanges")
718
+ if mode == "async_route_v1":
719
+ # Ask is an explicitly sent question in this mode. Any response is a
720
+ # separate message, so neither alias opens a legacy exchange or wait.
721
+ action = "instruction"
722
+ live_wait = (
723
+ action == "request_reply"
724
+ and mode != "async_route_v1"
725
+ and not bool(getattr(args, "async_response", False))
726
+ )
727
+ stable_key = "cli_" + hashlib.sha256(
728
+ (
729
+ f"{capability}\0{action}\0"
730
+ f"{'route' if route else 'target'}\0{destination}\0"
731
+ f"{int(live_wait)}\0{message}" + (f"\0reply:{reply_to}" if reply_to else "")
732
+ ).encode("utf-8")
733
+ ).hexdigest()
734
+ payload: dict[str, Any] = {
735
+ "action": action,
736
+ "body": message,
737
+ "idempotency_key": args.idempotency_key or stable_key,
738
+ "artifact_grants": [],
739
+ }
740
+ if mode:
741
+ payload["mode"] = mode
742
+ if reply_to:
743
+ payload["reply_to_message_id"] = reply_to
744
+ if live_wait:
745
+ heartbeat_seconds = live_response_heartbeat_seconds(
746
+ int(getattr(
747
+ args,
748
+ "timeout_seconds",
749
+ LIVE_RESPONSE_HEARTBEAT_SECONDS,
750
+ ))
751
+ )
752
+ payload["wait_for_response"] = True
753
+ payload["response_timeout_seconds"] = heartbeat_seconds
754
+ if route:
755
+ path = (
756
+ "/api/agent/cross-chat/routes/"
757
+ f"{urllib.parse.quote(route, safe='')}/handoffs"
758
+ )
759
+ else:
760
+ path = "/api/agent/cross-chat/handoffs"
761
+ payload["target_session_id"] = target
762
+ result = post_json(path, payload, capability)
763
+ if mode == "async_route_v1":
764
+ receipt_fields = {"ok", "route_id", "action", "accepted", "mode", "message_id", "duplicate"}
765
+ mailbox_fields = {"delivery_mode", "state", "execution_started"}
766
+ if (set(result) not in (receipt_fields, receipt_fields | mailbox_fields,
767
+ receipt_fields | mailbox_fields | {"wake_policy"})
768
+ or result.get("ok") is not True or result.get("accepted") is not True
769
+ or result.get("route_id") != route or result.get("action") != "instruction"
770
+ or result.get("mode") != mode or not isinstance(result.get("duplicate"), bool)
771
+ or ("delivery_mode" in result and (result.get("delivery_mode") != "mailbox"
772
+ or result.get("state") not in {"unread", "read", "cancelled", "deleted"}
773
+ or result.get("execution_started") is not False))
774
+ or ("wake_policy" in result and result["wake_policy"] != "idle_only")
775
+ or re.fullmatch(r"handoff_[0-9a-f]{32}", str(result.get("message_id") or "")) is None):
776
+ raise ChatsCLIError(
777
+ "AgentsServer returned an invalid asynchronous message receipt. "
778
+ "Delivery may already be stored; do not resend with a new idempotency key."
779
+ )
780
+ return result
781
+ minimal_expected = {"ok", "action", "accepted"}
782
+ if route:
783
+ minimal_expected.add("route_id")
784
+ expected = set(minimal_expected)
785
+ deferred_expected = set(minimal_expected)
786
+ wait_expected = set(minimal_expected)
787
+ pending_expected = set(minimal_expected)
788
+ if live_wait:
789
+ expected.update({
790
+ "exchange_id",
791
+ "inbound_leg_id",
792
+ "body",
793
+ "request_response",
794
+ })
795
+ wait_expected.update({
796
+ "exchange_id",
797
+ "inbound_leg_id",
798
+ "live_response_lease_id",
799
+ })
800
+ pending_expected.update({
801
+ "exchange_id",
802
+ "inbound_leg_id",
803
+ "live_response_lease_id",
804
+ "pending",
805
+ })
806
+ deferred_expected.update({
807
+ "exchange_id",
808
+ "inbound_leg_id",
809
+ "deferred",
810
+ "delivery",
811
+ "message",
812
+ })
813
+ if frozenset(result) == frozenset(wait_expected):
814
+ result = {
815
+ **{key: result[key] for key in minimal_expected},
816
+ "exchange_id": result["exchange_id"],
817
+ "inbound_leg_id": result["inbound_leg_id"],
818
+ "live_response_lease_id": result["live_response_lease_id"],
819
+ "pending": True,
820
+ }
821
+ elif frozenset(result) == frozenset(minimal_expected):
822
+ raise ChatsCLIError(
823
+ "AgentsServer does not support a live response for this route"
824
+ )
825
+ has_live_response = live_wait and frozenset(result) == frozenset(expected)
826
+ has_deferred_response = (
827
+ live_wait and frozenset(result) == frozenset(deferred_expected)
828
+ )
829
+ has_pending_response = (
830
+ live_wait and frozenset(result) == frozenset(pending_expected)
831
+ )
832
+ if route:
833
+ if (
834
+ frozenset(result) not in {
835
+ frozenset(minimal_expected),
836
+ frozenset(expected),
837
+ frozenset(deferred_expected),
838
+ frozenset(pending_expected),
839
+ }
840
+ or result.get("ok") is not True
841
+ or result.get("route_id") != route
842
+ or result.get("action") != action
843
+ or result.get("accepted") is not True
844
+ or (has_live_response and not isinstance(result.get("body"), str))
845
+ or (
846
+ has_pending_response
847
+ and (
848
+ result.get("pending") is not True
849
+ or not isinstance(result.get("live_response_lease_id"), str)
850
+ )
851
+ )
852
+ or (
853
+ has_deferred_response
854
+ and (
855
+ result.get("deferred") is not True
856
+ or result.get("delivery") != "asynchronous"
857
+ )
858
+ )
859
+ ):
860
+ raise ChatsCLIError("AgentsServer returned an invalid route handoff response")
861
+ else:
862
+ if (
863
+ frozenset(result) not in {
864
+ frozenset(minimal_expected),
865
+ frozenset(expected),
866
+ frozenset(deferred_expected),
867
+ frozenset(pending_expected),
868
+ }
869
+ or result.get("ok") is not True
870
+ or result.get("action") != action
871
+ or result.get("accepted") is not True
872
+ or (has_live_response and not isinstance(result.get("body"), str))
873
+ or (
874
+ has_pending_response
875
+ and (
876
+ result.get("pending") is not True
877
+ or not isinstance(result.get("live_response_lease_id"), str)
878
+ )
879
+ )
880
+ or (
881
+ has_deferred_response
882
+ and (
883
+ result.get("deferred") is not True
884
+ or result.get("delivery") != "asynchronous"
885
+ )
886
+ )
887
+ ):
888
+ raise ChatsCLIError("AgentsServer returned an invalid direct handoff response")
889
+ return result
890
+
891
+
892
+ def send(args: argparse.Namespace) -> dict[str, Any]:
893
+ return send_action(args, "instruction")
894
+
895
+
896
+ def ask(args: argparse.Namespace) -> dict[str, Any]:
897
+ return send_action(args, "request_reply")
898
+
899
+
900
+ def respond(args: argparse.Namespace) -> dict[str, Any]:
901
+ capability = authority(args.authority_file)
902
+ message = str(args.message or "").strip()
903
+ if not message:
904
+ raise ChatsCLIError("--message must not be empty")
905
+ request_response = bool(args.request_response)
906
+ async_response = bool(getattr(args, "async_response", False))
907
+ if async_response and not request_response:
908
+ raise ChatsCLIError("--async-response requires --request-response")
909
+ live_wait = request_response and not async_response
910
+ stable_key = "cli_" + hashlib.sha256(
911
+ (
912
+ f"{capability}\0respond\0{args.exchange}\0{args.inbound_leg}\0"
913
+ f"{int(request_response)}\0{int(live_wait)}\0{message}"
914
+ ).encode("utf-8")
915
+ ).hexdigest()
916
+ payload = {
917
+ "inbound_leg_id": args.inbound_leg,
918
+ "body": message,
919
+ "request_response": request_response,
920
+ "idempotency_key": args.idempotency_key or stable_key,
921
+ "artifact_grants": [],
922
+ }
923
+ if live_wait:
924
+ heartbeat_seconds = live_response_heartbeat_seconds(
925
+ int(getattr(
926
+ args,
927
+ "timeout_seconds",
928
+ LIVE_RESPONSE_HEARTBEAT_SECONDS,
929
+ ))
930
+ )
931
+ payload["wait_for_response"] = True
932
+ payload["response_timeout_seconds"] = heartbeat_seconds
933
+ result = post_json(
934
+ f"/api/agent/cross-chat/exchanges/{urllib.parse.quote(args.exchange, safe='')}/responses",
935
+ payload,
936
+ capability,
937
+ )
938
+ minimal_expected = {"ok", "action", "accepted"}
939
+ expected = set(minimal_expected)
940
+ deferred_expected = set(minimal_expected)
941
+ wait_expected = set(minimal_expected)
942
+ pending_expected = set(minimal_expected)
943
+ if live_wait:
944
+ expected.update({
945
+ "exchange_id",
946
+ "inbound_leg_id",
947
+ "body",
948
+ "request_response",
949
+ })
950
+ wait_expected.update({
951
+ "exchange_id",
952
+ "inbound_leg_id",
953
+ "live_response_lease_id",
954
+ })
955
+ pending_expected.update({
956
+ "exchange_id",
957
+ "inbound_leg_id",
958
+ "live_response_lease_id",
959
+ "pending",
960
+ })
961
+ deferred_expected.update({
962
+ "exchange_id",
963
+ "inbound_leg_id",
964
+ "deferred",
965
+ "delivery",
966
+ "message",
967
+ })
968
+ if frozenset(result) == frozenset(wait_expected):
969
+ result = {
970
+ **{key: result[key] for key in minimal_expected},
971
+ "exchange_id": result["exchange_id"],
972
+ "inbound_leg_id": result["inbound_leg_id"],
973
+ "live_response_lease_id": result["live_response_lease_id"],
974
+ "pending": True,
975
+ }
976
+ elif frozenset(result) == frozenset(minimal_expected):
977
+ raise ChatsCLIError(
978
+ "AgentsServer does not support a live follow-up response"
979
+ )
980
+ has_live_response = live_wait and frozenset(result) == frozenset(expected)
981
+ has_deferred_response = (
982
+ live_wait and frozenset(result) == frozenset(deferred_expected)
983
+ )
984
+ has_pending_response = (
985
+ live_wait and frozenset(result) == frozenset(pending_expected)
986
+ )
987
+ if (
988
+ frozenset(result) not in {
989
+ frozenset(minimal_expected),
990
+ frozenset(expected),
991
+ frozenset(deferred_expected),
992
+ frozenset(pending_expected),
993
+ }
994
+ or result.get("ok") is not True
995
+ or result.get("action") != "response"
996
+ or result.get("accepted") is not True
997
+ or (has_live_response and not isinstance(result.get("body"), str))
998
+ or (
999
+ has_pending_response
1000
+ and (
1001
+ result.get("pending") is not True
1002
+ or not isinstance(result.get("live_response_lease_id"), str)
1003
+ )
1004
+ )
1005
+ or (
1006
+ has_deferred_response
1007
+ and (
1008
+ result.get("deferred") is not True
1009
+ or result.get("delivery") != "asynchronous"
1010
+ )
1011
+ )
1012
+ ):
1013
+ raise ChatsCLIError(
1014
+ "AgentsServer returned an invalid cross-chat response"
1015
+ )
1016
+ return result
1017
+
1018
+
1019
+ def read_message_stdin() -> str:
1020
+ """Read an explicitly selected body, bounded before any authority or I/O."""
1021
+ if sys.stdin.isatty():
1022
+ raise ChatsCLIError("--message-stdin requires piped text; no message was sent")
1023
+ stream = getattr(sys.stdin, "buffer", sys.stdin)
1024
+ raw = stream.read(MESSAGE_STDIN_MAX_BYTES + 1)
1025
+ try:
1026
+ message = raw.decode("utf-8") if isinstance(raw, bytes) else raw
1027
+ size = len(message.encode("utf-8"))
1028
+ except (UnicodeDecodeError, UnicodeEncodeError) as exc:
1029
+ raise ChatsCLIError("message stdin is not valid UTF-8; no message was sent") from exc
1030
+ if size > MESSAGE_STDIN_MAX_BYTES or len(message) > MESSAGE_STDIN_MAX_CHARS:
1031
+ raise ChatsCLIError("message stdin is too large; no message was sent")
1032
+ if "\x00" in message or not message.strip():
1033
+ raise ChatsCLIError("message stdin must contain nonempty text; no message was sent")
1034
+ return message
1035
+
1036
+
1037
+ def add_message_arguments(command: argparse.ArgumentParser) -> None:
1038
+ command.epilog = (
1039
+ "Preserve normal word spacing, punctuation, and paragraph breaks in message bodies; "
1040
+ "keep technical summaries concise without concatenating words or numbers."
1041
+ )
1042
+ body = command.add_mutually_exclusive_group(required=True)
1043
+ body.add_argument("--message")
1044
+ body.add_argument("--message-stdin", action="store_true", help="read the message body from stdin")
1045
+
1046
+
1047
+ def parser() -> argparse.ArgumentParser:
1048
+ root = argparse.ArgumentParser(
1049
+ description="Contact an eligible chat on this AgentsDock server.",
1050
+ allow_abbrev=False,
1051
+ )
1052
+ root.add_argument(
1053
+ "--authority-file",
1054
+ help=(
1055
+ "mode-0600 per-run authority file; defaults to the live provider "
1056
+ "environment"
1057
+ ),
1058
+ )
1059
+ commands = root.add_subparsers(dest="command", required=True)
1060
+ list_command = commands.add_parser(
1061
+ "list",
1062
+ help="list one page of eligible same-server chats for this live run",
1063
+ allow_abbrev=False,
1064
+ )
1065
+ list_command.add_argument(
1066
+ "--cursor",
1067
+ help="continue listing with the previous response's non-null next_cursor",
1068
+ )
1069
+ list_command.set_defaults(handler=list_routes)
1070
+ inbox_command = commands.add_parser(
1071
+ "inbox", help="list pending senders without reading or running their messages", allow_abbrev=False,
1072
+ )
1073
+ inbox_command.add_argument("--cursor", help="opaque next_cursor from the previous sender page")
1074
+ inbox_command.set_defaults(handler=inbox)
1075
+ read_command = commands.add_parser(
1076
+ "read", help="explicitly read and claim one sender's pending messages; never automatically reply", allow_abbrev=False,
1077
+ )
1078
+ read_command.add_argument("--sender", required=True)
1079
+ read_command.add_argument("--request-id", required=True, help="stable 8 to 128 character receipt ID; reuse exactly on retry")
1080
+ read_command.add_argument("--cursor", type=inbox_sequence, default=0)
1081
+ read_command.add_argument("--limit", type=int, choices=range(1, 26), default=25)
1082
+ read_command.set_defaults(handler=read_inbox)
1083
+ command = commands.add_parser(
1084
+ "send",
1085
+ help="send one authorized instruction",
1086
+ allow_abbrev=False,
1087
+ )
1088
+ send_destination = command.add_mutually_exclusive_group(required=True)
1089
+ send_destination.add_argument("--route")
1090
+ send_destination.add_argument("--target")
1091
+ send_destination.add_argument("--target-index", type=positive_target_index)
1092
+ add_message_arguments(command)
1093
+ command.add_argument("--idempotency-key")
1094
+ command.add_argument("--mode", choices=["async_route_v1"])
1095
+ command.add_argument("--reply-to", help="exact received message ID; asynchronous routes only")
1096
+ command.set_defaults(handler=send)
1097
+ ask_command = commands.add_parser(
1098
+ "ask",
1099
+ help="ask a same-server agent and wait until it answers or is stopped",
1100
+ allow_abbrev=False,
1101
+ )
1102
+ ask_destination = ask_command.add_mutually_exclusive_group(required=True)
1103
+ ask_destination.add_argument("--route")
1104
+ ask_destination.add_argument("--target")
1105
+ ask_destination.add_argument("--target-index", type=positive_target_index)
1106
+ add_message_arguments(ask_command)
1107
+ ask_command.add_argument("--idempotency-key")
1108
+ ask_command.add_argument("--mode", choices=["async_route_v1"])
1109
+ ask_command.add_argument("--reply-to", help="exact received message ID; asynchronous routes only")
1110
+ ask_command.add_argument(
1111
+ "--async-response",
1112
+ action="store_true",
1113
+ help=(
1114
+ "return after durable send and receive the peer reply in a later "
1115
+ "turn (required for secure-peer routes)"
1116
+ ),
1117
+ )
1118
+ ask_command.add_argument(
1119
+ "--timeout-seconds",
1120
+ type=int,
1121
+ choices=range(1, 3601),
1122
+ default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
1123
+ help=(
1124
+ "deprecated compatibility value; live same-server waits have no "
1125
+ "response deadline"
1126
+ ),
1127
+ )
1128
+ ask_command.set_defaults(handler=ask)
1129
+ response_command = commands.add_parser(
1130
+ "respond",
1131
+ help="respond to the exact inbound exchange leg",
1132
+ allow_abbrev=False,
1133
+ )
1134
+ response_command.add_argument("--exchange", required=True)
1135
+ response_command.add_argument("--inbound-leg", required=True)
1136
+ add_message_arguments(response_command)
1137
+ response_command.add_argument("--request-response", action="store_true")
1138
+ response_command.add_argument(
1139
+ "--async-response",
1140
+ action="store_true",
1141
+ help=(
1142
+ "with --request-response, receive the peer reply in a later turn "
1143
+ "instead of waiting on this provider call"
1144
+ ),
1145
+ )
1146
+ response_command.add_argument("--idempotency-key")
1147
+ response_command.add_argument(
1148
+ "--timeout-seconds",
1149
+ type=int,
1150
+ choices=range(1, 3601),
1151
+ default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
1152
+ help=(
1153
+ "deprecated compatibility value; live same-server waits have no "
1154
+ "response deadline"
1155
+ ),
1156
+ )
1157
+ response_command.set_defaults(handler=respond)
1158
+ current_response_command = commands.add_parser(
1159
+ "respond-current",
1160
+ help="respond using this run's current inbound reply grant",
1161
+ allow_abbrev=False,
1162
+ )
1163
+ add_message_arguments(current_response_command)
1164
+ current_response_command.add_argument(
1165
+ "--request-response",
1166
+ action="store_true",
1167
+ )
1168
+ current_response_command.add_argument("--idempotency-key")
1169
+ current_response_command.add_argument(
1170
+ "--timeout-seconds",
1171
+ type=int,
1172
+ choices=range(1, 3601),
1173
+ default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
1174
+ help=(
1175
+ "deprecated compatibility value; live same-server waits have no "
1176
+ "response deadline"
1177
+ ),
1178
+ )
1179
+ current_response_command.set_defaults(handler=respond_current)
1180
+ wait_command = commands.add_parser(
1181
+ "wait",
1182
+ help=(
1183
+ "observe one bounded foreground slice of a pending same-server "
1184
+ "request"
1185
+ ),
1186
+ allow_abbrev=False,
1187
+ )
1188
+ wait_command.add_argument("--exchange", required=True)
1189
+ wait_command.add_argument("--inbound-leg", required=True)
1190
+ wait_command.add_argument("--lease", required=True)
1191
+ wait_command.add_argument(
1192
+ "--timeout-seconds",
1193
+ type=int,
1194
+ choices=range(1, 3601),
1195
+ default=LIVE_RESPONSE_HEARTBEAT_SECONDS,
1196
+ help=(
1197
+ "bounded transport slice only; repeat wait after every pending "
1198
+ "receipt"
1199
+ ),
1200
+ )
1201
+ wait_command.set_defaults(handler=wait)
1202
+ return root
1203
+
1204
+
1205
+ def main(argv: list[str] | None = None) -> int:
1206
+ previous_authority_file = os.environ.get(
1207
+ "AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
1208
+ )
1209
+ try:
1210
+ args = parser().parse_args(argv)
1211
+ if getattr(args, "message_stdin", False):
1212
+ args.message = read_message_stdin()
1213
+ selected_authority = _authority_path(args.authority_file)
1214
+ os.environ["AGENTSDOCK_PROVIDER_AUTHORITY_FILE"] = str(
1215
+ selected_authority
1216
+ )
1217
+ result = args.handler(args)
1218
+ print(json.dumps(result, ensure_ascii=False))
1219
+ # A retryable live-response transport failure is structured so the
1220
+ # caller retains its exact lease, but it is not a successful pending
1221
+ # observation. Exit nonzero after printing the receipt so automation
1222
+ # cannot silently treat network ambiguity as server-owned waiting.
1223
+ return 2 if result.get("transport_error") is True else 0
1224
+ except ChatsCLIError as exc:
1225
+ print(f"agentsdock-chats: {exc}", file=sys.stderr)
1226
+ return 2
1227
+ finally:
1228
+ if previous_authority_file is None:
1229
+ os.environ.pop("AGENTSDOCK_PROVIDER_AUTHORITY_FILE", None)
1230
+ else:
1231
+ os.environ[
1232
+ "AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
1233
+ ] = previous_authority_file
1234
+
1235
+
1236
+ if __name__ == "__main__":
1237
+ raise SystemExit(main())