@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,793 @@
1
+ #!/usr/bin/env python3
2
+ """Capability-scoped Team Network helper for AgentsDock agents.
3
+
4
+ Read commands (mentions, inbox, bulletin/feed, sent, read, skills, skill get) are available on
5
+ every ordinary turn. ``routes``, ``send``, ``reply``, and ``edit`` exist only when the user
6
+ mentioned Team Network recipients with ``@@`` on this turn; the server freezes
7
+ those recipients into opaque per-run routes. Message bodies arrive on stdin so
8
+ they never appear in process arguments.
9
+
10
+ The Bulletin route posts only to the shared board. The all_servers route sends
11
+ Team Network mail to each current server inbox, not to an email/SMTP address.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import ipaddress
19
+ import json
20
+ import os
21
+ import re
22
+ import sys
23
+ import time
24
+ import unicodedata
25
+ import urllib.error
26
+ import urllib.parse
27
+ import urllib.request
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+
32
+ BODY_MAX_BYTES = 49_152
33
+ ATTACHMENT_MAX_COUNT = 16
34
+ PROVIDER_RUNTIME_VALUE_MAX_BYTES = 4096
35
+ SENDER_SCAN_PAGE_SIZE = 50
36
+ SENDER_SCAN_MAX_RESULTS = 50
37
+ SENDER_SCAN_MAX_PAGES = 20
38
+ SENDER_SCAN_MAX_SECONDS = 45.0
39
+ # Keep complete, pretty-printed responses below the provider's 128 KiB stdout cap.
40
+ SENDER_SCAN_OUTPUT_MAX_BYTES = 96 * 1024
41
+
42
+
43
+ class TeamCLIError(RuntimeError):
44
+ pass
45
+
46
+
47
+ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
48
+ def redirect_request(self, *_args: Any, **_kwargs: Any) -> None:
49
+ return None
50
+
51
+
52
+ def _canonical_http_origin(value: str, label: str) -> tuple[str, bool]:
53
+ raw = value.strip()
54
+ try:
55
+ parsed = urllib.parse.urlsplit(raw)
56
+ port = parsed.port or 80
57
+ except ValueError as exc:
58
+ raise TeamCLIError(f"{label} must be an HTTP origin") from exc
59
+ if (
60
+ parsed.scheme.lower() != "http"
61
+ or not parsed.hostname
62
+ or parsed.username is not None
63
+ or parsed.password is not None
64
+ or parsed.path not in {"", "/"}
65
+ or parsed.query
66
+ or parsed.fragment
67
+ ):
68
+ raise TeamCLIError(f"{label} must be an HTTP origin")
69
+ host = parsed.hostname.lower()
70
+ try:
71
+ address = ipaddress.ip_address(host)
72
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
73
+ address = address.ipv4_mapped
74
+ loopback = address.is_loopback
75
+ host = address.compressed
76
+ url_host = f"[{host}]" if isinstance(address, ipaddress.IPv6Address) else host
77
+ except ValueError:
78
+ loopback = host == "localhost"
79
+ url_host = host
80
+ return f"http://{url_host}:{port}", loopback
81
+
82
+
83
+ def _authority_server_origin(authority_file: str | None) -> str:
84
+ path = _selected_authority_path(authority_file)
85
+ try:
86
+ if path.stat().st_mode & 0o077:
87
+ raise TeamCLIError("authority file permissions are unsafe")
88
+ payload = json.loads(path.read_text(encoding="utf-8"))
89
+ except (OSError, json.JSONDecodeError) as exc:
90
+ raise TeamCLIError(f"could not read authority file: {exc}") from exc
91
+ return _bounded_identity_value(
92
+ payload.get("provider_server_origin"),
93
+ "authority provider_server_origin",
94
+ )
95
+
96
+
97
+ def _loopback_server_url() -> str:
98
+ raw_server_url = os.environ.get("AGENTSDOCK_SERVER_URL", "").strip()
99
+ if not raw_server_url:
100
+ raise TeamCLIError("missing AgentsDock agent environment")
101
+ server_origin, loopback = _canonical_http_origin(
102
+ raw_server_url,
103
+ "AGENTSDOCK_SERVER_URL",
104
+ )
105
+ runtime_origin = _bounded_identity_value(
106
+ os.environ.get("AGENTSDOCK_PROVIDER_SERVER_ORIGIN"),
107
+ "AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
108
+ )
109
+ if runtime_origin:
110
+ canonical_runtime, _runtime_loopback = _canonical_http_origin(
111
+ runtime_origin,
112
+ "AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
113
+ )
114
+ if canonical_runtime != server_origin:
115
+ raise TeamCLIError(
116
+ "AGENTSDOCK_SERVER_URL conflicts with the live provider origin"
117
+ )
118
+ if loopback:
119
+ return raw_server_url.rstrip("/")
120
+ authority_origin = _authority_server_origin(None)
121
+ if not authority_origin:
122
+ raise TeamCLIError(
123
+ "non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
124
+ )
125
+ canonical_authority, _authority_loopback = _canonical_http_origin(
126
+ authority_origin,
127
+ "authority provider_server_origin",
128
+ )
129
+ if canonical_authority != server_origin:
130
+ raise TeamCLIError(
131
+ "non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
132
+ )
133
+ return server_origin
134
+
135
+
136
+ def _bounded_identity_value(value: str | None, label: str) -> str:
137
+ clean = str(value or "").strip()
138
+ try:
139
+ size = len(clean.encode("utf-8"))
140
+ except UnicodeEncodeError as exc:
141
+ raise TeamCLIError(f"{label} is not valid UTF-8") from exc
142
+ if size > PROVIDER_RUNTIME_VALUE_MAX_BYTES:
143
+ raise TeamCLIError(f"{label} exceeds the provider runtime limit")
144
+ return clean
145
+
146
+
147
+ def _selected_authority_path(authority_file: str | None) -> Path:
148
+ explicit = _bounded_identity_value(authority_file, "--authority-file")
149
+ ambient = _bounded_identity_value(
150
+ os.environ.get("AGENTSDOCK_PROVIDER_AUTHORITY_FILE"),
151
+ "AGENTSDOCK_PROVIDER_AUTHORITY_FILE",
152
+ )
153
+ if explicit and ambient:
154
+ explicit_key = os.path.abspath(os.path.expanduser(explicit))
155
+ ambient_key = os.path.abspath(os.path.expanduser(ambient))
156
+ if explicit_key != ambient_key:
157
+ raise TeamCLIError(
158
+ "--authority-file conflicts with the live provider authority"
159
+ )
160
+ selected = explicit or ambient
161
+ if not selected:
162
+ raise TeamCLIError("--authority-file is required")
163
+ return Path(selected).expanduser()
164
+
165
+
166
+ def _provider_authority(authority_file: str | None) -> tuple[str, str]:
167
+ path = _selected_authority_path(authority_file)
168
+ try:
169
+ if path.stat().st_mode & 0o077:
170
+ raise TeamCLIError("authority file permissions are unsafe")
171
+ payload = json.loads(path.read_text(encoding="utf-8"))
172
+ except (OSError, json.JSONDecodeError) as exc:
173
+ raise TeamCLIError(f"could not read authority file: {exc}") from exc
174
+ capability = str(payload.get("provider_capability") or payload.get("capability") or "")
175
+ source_session_id = str(payload.get("source_session_id") or "").strip()
176
+ if not capability or not source_session_id:
177
+ raise TeamCLIError("authority file is invalid")
178
+ environment_chat_id = _bounded_identity_value(
179
+ os.environ.get("AGENTSDOCK_CHAT_ID"),
180
+ "AGENTSDOCK_CHAT_ID",
181
+ )
182
+ if environment_chat_id and environment_chat_id != source_session_id:
183
+ raise TeamCLIError(
184
+ "AGENTSDOCK_CHAT_ID does not match the authority file"
185
+ )
186
+ return capability, source_session_id
187
+
188
+
189
+ def _request_json(
190
+ method: str,
191
+ path: str,
192
+ capability: str,
193
+ payload: dict[str, Any] | None = None,
194
+ *,
195
+ timeout: float = 60.0,
196
+ ) -> dict[str, Any]:
197
+ body = None if payload is None else json.dumps(payload).encode("utf-8")
198
+ request = urllib.request.Request(
199
+ f"{_loopback_server_url()}{path}",
200
+ data=body,
201
+ headers={
202
+ "Accept": "application/json",
203
+ "Content-Type": "application/json",
204
+ "X-AgentsDock-Provider-Capability": capability,
205
+ },
206
+ method=method,
207
+ )
208
+ opener = urllib.request.build_opener(
209
+ urllib.request.ProxyHandler({}),
210
+ NoRedirectHandler(),
211
+ )
212
+ try:
213
+ with opener.open(request, timeout=timeout) as response:
214
+ result = json.loads(response.read().decode("utf-8"))
215
+ except urllib.error.HTTPError as exc:
216
+ raw = exc.read().decode("utf-8", errors="replace")
217
+ try:
218
+ detail = json.loads(raw).get("detail") or raw
219
+ except (json.JSONDecodeError, AttributeError):
220
+ detail = raw
221
+ raise TeamCLIError(
222
+ f"server rejected Team Network request ({exc.code}): {detail or exc.reason}"
223
+ ) from exc
224
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
225
+ raise TeamCLIError(
226
+ f"could not reach AgentsServer: {getattr(exc, 'reason', exc)}"
227
+ ) from exc
228
+ if not isinstance(result, dict):
229
+ raise TeamCLIError("AgentsServer returned an invalid response")
230
+ return result
231
+
232
+
233
+ def _query(params: dict[str, Any]) -> str:
234
+ clean = {key: value for key, value in params.items() if value not in (None, "", False)}
235
+ if not clean:
236
+ return ""
237
+ return "?" + urllib.parse.urlencode(
238
+ {key: ("1" if value is True else str(value)) for key, value in clean.items()}
239
+ )
240
+
241
+
242
+ def _sender_name(value: str) -> str:
243
+ name = value.strip()
244
+ if name.startswith("@@"):
245
+ name = name[2:].strip()
246
+ if not name:
247
+ raise TeamCLIError("--from requires a sender display name")
248
+ return name
249
+
250
+
251
+ def _sender_output_size(result: dict[str, Any]) -> int:
252
+ # Match main()'s JSON formatting and print's final newline, including metadata.
253
+ return len(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False).encode("utf-8")) + 1
254
+
255
+
256
+ def _list_by_sender(
257
+ capability: str, params: dict[str, Any], name: str, limit: int,
258
+ ) -> dict[str, Any]:
259
+ """Scan existing ascending pages on demand, retaining a lossless cursor."""
260
+ if limit < 1:
261
+ raise TeamCLIError("--limit must be positive")
262
+ limit = min(limit, SENDER_SCAN_MAX_RESULTS)
263
+ needle = name.casefold()
264
+ cursor = max(0, params.get("after_sequence") or 0)
265
+ params = {**params, "limit": SENDER_SCAN_PAGE_SIZE}
266
+ deadline = time.monotonic() + SENDER_SCAN_MAX_SECONDS
267
+ matches: list[dict[str, Any]] = []
268
+ scanned = 0
269
+ pages = 0
270
+ result: dict[str, Any] | None = None
271
+ has_more = True
272
+ stop_reason = "page_limit"
273
+
274
+ def response(*, candidate: dict[str, Any] | None = None) -> dict[str, Any]:
275
+ return {
276
+ **(result or {}),
277
+ "messages": [*matches, candidate] if candidate is not None else matches,
278
+ "next_after_sequence": candidate["sequence"] if candidate is not None else cursor,
279
+ # This describes unexamined source messages, not confirmed matches.
280
+ "has_more": True if candidate is not None else has_more,
281
+ "sender_filter": name,
282
+ "scan_complete": False if candidate is not None else not has_more,
283
+ "scan_stop_reason": "output_limit" if candidate is not None else stop_reason,
284
+ # Reserve enough digits for later scan counters when checking a match.
285
+ "scanned_pages": SENDER_SCAN_MAX_PAGES if candidate is not None else pages,
286
+ "scanned_messages": SENDER_SCAN_MAX_PAGES * SENDER_SCAN_PAGE_SIZE if candidate is not None else scanned,
287
+ }
288
+
289
+ while pages < SENDER_SCAN_MAX_PAGES:
290
+ remaining = deadline - time.monotonic()
291
+ if remaining <= 0:
292
+ stop_reason = "time_limit"
293
+ break
294
+ result = _request_json(
295
+ "GET", "/api/agent/team/messages"
296
+ + _query({**params, "after_sequence": cursor}),
297
+ capability, timeout=remaining,
298
+ )
299
+ messages = result.get("messages")
300
+ next_cursor = result.get("next_after_sequence")
301
+ has_more = result.get("has_more")
302
+ if (
303
+ not isinstance(messages, list)
304
+ or len(messages) > SENDER_SCAN_PAGE_SIZE
305
+ or type(has_more) is not bool
306
+ or type(next_cursor) is not int
307
+ ):
308
+ raise TeamCLIError("AgentsServer returned an invalid message page")
309
+ previous = cursor
310
+ for item in messages:
311
+ if (
312
+ not isinstance(item, dict)
313
+ or type(item.get("sequence")) is not int
314
+ or item["sequence"] <= previous
315
+ or not isinstance(item.get("sender"), dict)
316
+ ):
317
+ raise TeamCLIError("AgentsServer returned an invalid message page cursor or sender")
318
+ previous = item["sequence"]
319
+ if next_cursor != previous or (has_more and next_cursor <= cursor):
320
+ raise TeamCLIError("AgentsServer returned an invalid or nonadvancing message page cursor")
321
+ page_team = result.get("team_id")
322
+ if page_team:
323
+ if params.get("team") and params["team"] != page_team:
324
+ raise TeamCLIError("AgentsServer changed teams during a sender read")
325
+ params["team"] = page_team
326
+ pages += 1
327
+ for index, item in enumerate(messages):
328
+ display_name = str(item["sender"].get("display_name") or "")
329
+ if display_name.strip().casefold() == needle:
330
+ candidate = response(candidate=item)
331
+ if _sender_output_size(candidate) > SENDER_SCAN_OUTPUT_MAX_BYTES:
332
+ if _sender_output_size({**candidate, "messages": [item]}) > SENDER_SCAN_OUTPUT_MAX_BYTES:
333
+ raise TeamCLIError("A matching message exceeds the sender read output limit")
334
+ # Do not consume this match: the next explicit read must see it.
335
+ has_more = True
336
+ stop_reason = "output_limit"
337
+ break
338
+ matches.append(item)
339
+ cursor = item["sequence"]
340
+ scanned += 1
341
+ if len(matches) >= limit:
342
+ # Unexamined entries in this page must remain available on the
343
+ # next explicit read, even if this was the server's final page.
344
+ has_more = index < len(messages) - 1 or has_more
345
+ break
346
+ if stop_reason == "output_limit":
347
+ break
348
+ if not has_more:
349
+ stop_reason = "exhausted"
350
+ break
351
+ if len(matches) >= limit:
352
+ stop_reason = "result_limit"
353
+ break
354
+ if result is None:
355
+ raise TeamCLIError("Sender read time budget expired before a page was received")
356
+ output = response()
357
+ if _sender_output_size(output) > SENDER_SCAN_OUTPUT_MAX_BYTES:
358
+ raise TeamCLIError("Sender read response metadata exceeds the output limit")
359
+ return output
360
+
361
+
362
+ def list_box(args: argparse.Namespace, box: str) -> dict[str, Any]:
363
+ capability, _session_id = _provider_authority(args.authority_file)
364
+ mention = getattr(args, "mention", None)
365
+ if mention is not None and mention < 1:
366
+ raise TeamCLIError("--mention must be a positive mention index from `mentions`")
367
+ params = {
368
+ "box": box,
369
+ "unread": getattr(args, "unread", False),
370
+ "since": getattr(args, "since", None),
371
+ "after_sequence": getattr(args, "after", None),
372
+ "limit": args.limit,
373
+ "team": getattr(args, "team", None),
374
+ "include_mail_subject": getattr(args, "include_mail_subject", False),
375
+ "mention": mention,
376
+ }
377
+ from_name = getattr(args, "from_name", None)
378
+ if from_name is not None:
379
+ return _list_by_sender(capability, params, _sender_name(from_name), args.limit)
380
+ result = _request_json(
381
+ "GET",
382
+ "/api/agent/team/messages" + _query(params),
383
+ capability,
384
+ )
385
+ messages = result.get("messages")
386
+ if not isinstance(messages, list):
387
+ raise TeamCLIError("AgentsServer returned an invalid message list")
388
+ return result
389
+
390
+
391
+ def inbox(args: argparse.Namespace) -> dict[str, Any]:
392
+ return list_box(args, "inbox")
393
+
394
+
395
+ def feed(args: argparse.Namespace) -> dict[str, Any]:
396
+ return list_box(args, "feed")
397
+
398
+
399
+ def sent(args: argparse.Namespace) -> dict[str, Any]:
400
+ return list_box(args, "sent")
401
+
402
+
403
+ def mentions(args: argparse.Namespace) -> dict[str, Any]:
404
+ capability, _session_id = _provider_authority(args.authority_file)
405
+ result = _request_json("GET", "/api/agent/team/mentions", capability)
406
+ if not isinstance(result.get("mentions"), list):
407
+ raise TeamCLIError("AgentsServer returned an invalid Team Network mention list")
408
+ return result
409
+
410
+
411
+ def read(args: argparse.Namespace) -> dict[str, Any]:
412
+ capability, _session_id = _provider_authority(args.authority_file)
413
+ message_id = str(args.message_id or "").strip()
414
+ if not message_id:
415
+ raise TeamCLIError("MESSAGE_ID is required")
416
+ return _request_json(
417
+ "GET",
418
+ f"/api/agent/team/messages/{urllib.parse.quote(message_id, safe='')}"
419
+ + _query({
420
+ "download": bool(args.download), "team": getattr(args, "team", None),
421
+ "include_mail_subject": getattr(args, "include_mail_subject", False),
422
+ "include_revision": getattr(args, "include_revision", False),
423
+ }),
424
+ capability,
425
+ timeout=600.0 if args.download else 60.0,
426
+ )
427
+
428
+
429
+ def skills(args: argparse.Namespace) -> dict[str, Any]:
430
+ capability, _session_id = _provider_authority(args.authority_file)
431
+ return _request_json(
432
+ "GET",
433
+ "/api/agent/team/skills"
434
+ + _query(
435
+ {
436
+ "include_archived": bool(args.include_archived),
437
+ "team": getattr(args, "team", None),
438
+ }
439
+ ),
440
+ capability,
441
+ )
442
+
443
+
444
+ def skill_get(args: argparse.Namespace) -> dict[str, Any]:
445
+ capability, _session_id = _provider_authority(args.authority_file)
446
+ slug = str(args.slug or "").strip().lower()
447
+ if not slug:
448
+ raise TeamCLIError("SLUG is required")
449
+ return _request_json(
450
+ "GET",
451
+ f"/api/agent/team/skills/{urllib.parse.quote(slug, safe='')}"
452
+ + _query(
453
+ {
454
+ "version": args.version,
455
+ "download": bool(args.download),
456
+ "team": getattr(args, "team", None),
457
+ }
458
+ ),
459
+ capability,
460
+ timeout=600.0 if args.download else 60.0,
461
+ )
462
+
463
+
464
+ def routes(args: argparse.Namespace) -> dict[str, Any]:
465
+ capability, _session_id = _provider_authority(args.authority_file)
466
+ result = _request_json("GET", "/api/agent/team/routes", capability)
467
+ if not isinstance(result.get("routes"), list):
468
+ raise TeamCLIError("AgentsServer returned an invalid route list")
469
+ return result
470
+
471
+
472
+ def _read_body() -> str:
473
+ if sys.stdin.isatty():
474
+ raise TeamCLIError("the message body must be provided on stdin")
475
+ stream = getattr(sys.stdin, "buffer", sys.stdin)
476
+ raw = stream.read(BODY_MAX_BYTES + 1)
477
+ if isinstance(raw, str):
478
+ raw = raw.encode("utf-8")
479
+ if len(raw) > BODY_MAX_BYTES:
480
+ raise TeamCLIError(f"the message body exceeds {BODY_MAX_BYTES} bytes")
481
+ try:
482
+ body = raw.decode("utf-8").strip()
483
+ except UnicodeDecodeError as exc:
484
+ raise TeamCLIError("the message body must be valid UTF-8") from exc
485
+ if not body:
486
+ raise TeamCLIError("the message body on stdin must not be empty")
487
+ return body
488
+
489
+
490
+ def _attachment_paths(values: list[str]) -> list[str]:
491
+ paths: list[str] = []
492
+ for value in values:
493
+ path = Path(value).expanduser()
494
+ if not path.is_absolute():
495
+ raise TeamCLIError(f"attachment paths must be absolute: {value}")
496
+ if not path.is_file():
497
+ raise TeamCLIError(f"attachment is not a regular file: {value}")
498
+ resolved = str(path.resolve())
499
+ if resolved not in paths:
500
+ paths.append(resolved)
501
+ if len(paths) > ATTACHMENT_MAX_COUNT:
502
+ raise TeamCLIError(f"at most {ATTACHMENT_MAX_COUNT} attachments per message")
503
+ return paths
504
+
505
+
506
+ def _mail_subject(value: str) -> str:
507
+ if any(unicodedata.category(char) in {"Cc", "Cs", "Zl", "Zp"} for char in value):
508
+ raise TeamCLIError("--title must be a single line without control characters")
509
+ subject = value.strip()
510
+ if not 1 <= len(subject) <= 160:
511
+ raise TeamCLIError("--title must be between 1 and 160 characters")
512
+ return subject
513
+
514
+
515
+ def send(args: argparse.Namespace) -> dict[str, Any]:
516
+ capability, _session_id = _provider_authority(args.authority_file)
517
+ route_id = str(args.route or "").strip()
518
+ if not route_id:
519
+ raise TeamCLIError("--route is required; run `routes` first")
520
+ kind = str(args.kind or "message")
521
+ if kind not in {"message", "skill"}:
522
+ raise TeamCLIError("--kind must be message or skill")
523
+ reply_id = getattr(args, "in_reply_to", None)
524
+ if reply_id is not None:
525
+ if kind != "message":
526
+ raise TeamCLIError("--in-reply-to requires --kind message")
527
+ if not isinstance(reply_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{8,240}", reply_id) is None:
528
+ raise TeamCLIError("reply MESSAGE_ID must contain 8 to 240 ASCII letters, digits, underscores, or hyphens")
529
+ title = (
530
+ _mail_subject(args.title) if kind == "message" and args.title is not None
531
+ else str(args.title).strip() if args.title else None
532
+ )
533
+ body = _read_body()
534
+ attachments = _attachment_paths(list(args.attach or []))
535
+ payload: dict[str, Any] = {
536
+ "kind": kind,
537
+ "body": body,
538
+ "body_format": "markdown",
539
+ "attachments": attachments,
540
+ }
541
+ if title is not None:
542
+ payload["title"] = title
543
+ if reply_id is not None:
544
+ payload["in_reply_to_message_id"] = reply_id
545
+ if kind == "skill":
546
+ if not args.skill_slug:
547
+ raise TeamCLIError("--skill-slug is required for --kind skill")
548
+ if not args.title:
549
+ raise TeamCLIError("--title is required for --kind skill")
550
+ skill: dict[str, Any] = {"slug": str(args.skill_slug).strip().lower()}
551
+ if args.summary:
552
+ skill["summary"] = str(args.summary).strip()
553
+ if args.tags:
554
+ skill["tags"] = [tag.strip().lower() for tag in str(args.tags).split(",") if tag.strip()]
555
+ if args.change_note:
556
+ skill["change_note"] = str(args.change_note).strip()
557
+ if args.expected_version is not None:
558
+ skill["expected_version"] = int(args.expected_version)
559
+ payload["skill"] = skill
560
+ elif args.skill_slug or args.expected_version is not None:
561
+ raise TeamCLIError("skill options require --kind skill")
562
+ stable_key = "team_cli_" + hashlib.sha256(
563
+ json.dumps(
564
+ [capability, route_id, payload],
565
+ sort_keys=True,
566
+ separators=(",", ":"),
567
+ ensure_ascii=False,
568
+ ).encode("utf-8")
569
+ ).hexdigest()
570
+ payload["idempotency_key"] = args.idempotency_key or stable_key
571
+ result = _request_json(
572
+ "POST",
573
+ f"/api/agent/team/routes/{urllib.parse.quote(route_id, safe='')}",
574
+ capability,
575
+ payload,
576
+ timeout=900.0,
577
+ )
578
+ required = {"ok", "route_id", "message_id", "kind", "accepted", "duplicate", "attachments"}
579
+ if (
580
+ not required.issubset(result)
581
+ or result.get("ok") is not True
582
+ or result.get("route_id") != route_id
583
+ or result.get("kind") != kind
584
+ or result.get("accepted") is not True
585
+ or type(result.get("duplicate")) is not bool
586
+ ):
587
+ raise TeamCLIError("AgentsServer returned an invalid Team Network send receipt")
588
+ return result
589
+
590
+
591
+ def edit(args: argparse.Namespace) -> dict[str, Any]:
592
+ """Revise one exact Bulletin message; never fall back to creating a post."""
593
+ capability, _session_id = _provider_authority(args.authority_file)
594
+ route_id = str(args.route or "").strip()
595
+ if not route_id:
596
+ raise TeamCLIError("--route is required; run `routes` first")
597
+ message_id = args.message_id
598
+ if not isinstance(message_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{8,240}", message_id) is None:
599
+ raise TeamCLIError("MESSAGE_ID must contain 8 to 240 ASCII letters, digits, underscores, or hyphens")
600
+ expected_version = args.expected_version
601
+ if type(expected_version) is not int or expected_version < 1:
602
+ raise TeamCLIError("--expected-version must be a positive integer; read --include-revision first")
603
+ payload: dict[str, Any] = {
604
+ "kind": "bulletin_edit",
605
+ "message_id": message_id,
606
+ "expected_version": expected_version,
607
+ "body": _read_body(),
608
+ "body_format": "markdown",
609
+ }
610
+ stable_key = "team_cli_" + hashlib.sha256(json.dumps(
611
+ [capability, route_id, payload], sort_keys=True, separators=(",", ":"), ensure_ascii=False,
612
+ ).encode("utf-8")).hexdigest()
613
+ payload["idempotency_key"] = args.idempotency_key or stable_key
614
+ result = _request_json(
615
+ "POST", f"/api/agent/team/routes/{urllib.parse.quote(route_id, safe='')}",
616
+ capability, payload, timeout=900.0,
617
+ )
618
+ if (
619
+ result.get("ok") is not True
620
+ or result.get("route_id") != route_id
621
+ or result.get("message_id") != message_id
622
+ or result.get("kind") != "bulletin_edit"
623
+ or result.get("accepted") is not True
624
+ or type(result.get("duplicate")) is not bool
625
+ or type(result.get("attachments")) is not int
626
+ or result["attachments"] < 0
627
+ or result.get("edited") is not True
628
+ or type(result.get("version")) is not int
629
+ or result["version"] != expected_version + 1
630
+ ):
631
+ raise TeamCLIError("AgentsServer returned an invalid Team Network edit receipt")
632
+ return result
633
+
634
+
635
+ def parser() -> argparse.ArgumentParser:
636
+ root = argparse.ArgumentParser(
637
+ description="Read native Team Network mail, Bulletin, and skills, and send to @@-mentioned recipients.",
638
+ allow_abbrev=False,
639
+ )
640
+ root.add_argument(
641
+ "--authority-file",
642
+ help="mode-0600 per-run AgentsDock provider authority file",
643
+ )
644
+ commands = root.add_subparsers(dest="command", required=True)
645
+
646
+ mentions_command = commands.add_parser(
647
+ "mentions", help="list this turn's selected @@ identities for exact Team Network reads", allow_abbrev=False,
648
+ )
649
+ mentions_command.set_defaults(handler=mentions)
650
+
651
+ def listing(name: str, help_text: str, *, aliases: tuple[str, ...] = ()):
652
+ command = commands.add_parser(name, aliases=list(aliases), help=help_text, allow_abbrev=False)
653
+ command.add_argument("--limit", type=int, default=20, help="maximum messages to return; with --from, maximum sender matches (capped at 50 and the output budget)")
654
+ command.add_argument("--after", type=int, default=None, help="sequence cursor")
655
+ command.add_argument("--team", default=None, help="team id when this server is in several teams")
656
+ command.add_argument("--include-mail-subject", action="store_true", help="request mail subjects from a compatible Team Hub")
657
+ return command
658
+
659
+ inbox_command = listing("inbox", "messages sent to this server")
660
+ inbox_command.add_argument("--unread", action="store_true")
661
+ sender_help = "exact case-insensitive sender display name, optionally prefixed @@; scans at most 20 pages/45 seconds; continue with --after next_after_sequence if has_more"
662
+ def sender_options(command: argparse.ArgumentParser) -> None:
663
+ selection = command.add_mutually_exclusive_group()
664
+ selection.add_argument("--from", dest="from_name", default=None, help=sender_help)
665
+ selection.add_argument("--mention", type=int, default=None, metavar="INDEX",
666
+ help="exact selected @@ identity from `mentions`; server scopes its team and sender")
667
+ sender_options(inbox_command)
668
+ inbox_command.add_argument("--since", default=None, help="ISO-8601 timestamp or epoch seconds")
669
+ inbox_command.set_defaults(handler=inbox)
670
+
671
+ feed_command = listing("feed", "shared Team Network Bulletin posts", aliases=("bulletin",))
672
+ sender_options(feed_command)
673
+ feed_command.add_argument("--since", default=None)
674
+ feed_command.set_defaults(handler=feed)
675
+
676
+ sent_command = listing("sent", "messages this server sent")
677
+ sent_command.set_defaults(handler=sent)
678
+
679
+ read_command = commands.add_parser(
680
+ "read", help="read one message with its attachments", allow_abbrev=False
681
+ )
682
+ read_command.add_argument("message_id")
683
+ read_command.add_argument("--download", action="store_true", help="fetch attachments into the local team cache and print their paths")
684
+ read_command.add_argument("--team", default=None)
685
+ read_command.add_argument("--include-mail-subject", action="store_true", help="request the mail subject from a compatible Team Hub")
686
+ read_command.add_argument("--include-revision", action="store_true", help="request the current Bulletin revision version before editing")
687
+ read_command.set_defaults(handler=read)
688
+
689
+ skills_command = commands.add_parser(
690
+ "skills", help="list the team Skills library", allow_abbrev=False
691
+ )
692
+ skills_command.add_argument("--include-archived", action="store_true")
693
+ skills_command.add_argument("--team", default=None)
694
+ skills_command.set_defaults(handler=skills)
695
+
696
+ skill_command = commands.add_parser(
697
+ "skill", help="skill operations", allow_abbrev=False
698
+ )
699
+ skill_sub = skill_command.add_subparsers(dest="skill_command", required=True)
700
+ skill_get_command = skill_sub.add_parser(
701
+ "get",
702
+ help="read one skill (latest version by default)",
703
+ allow_abbrev=False,
704
+ )
705
+ skill_get_command.add_argument("slug")
706
+ skill_get_command.add_argument("--version", type=int, default=None)
707
+ skill_get_command.add_argument("--download", action="store_true")
708
+ skill_get_command.add_argument("--team", default=None)
709
+ skill_get_command.set_defaults(handler=skill_get)
710
+
711
+ routes_command = commands.add_parser(
712
+ "routes",
713
+ help="list this run's @@ recipient routes",
714
+ allow_abbrev=False,
715
+ )
716
+ routes_command.set_defaults(handler=routes)
717
+
718
+ def message_send_options(command: argparse.ArgumentParser) -> None:
719
+ command.add_argument("--route", required=True)
720
+ command.add_argument("--title", default=None, help="optional single-line mail subject (max 160 characters; requires Hub support), or required skill title")
721
+ command.add_argument("--attach", action="append", default=[], metavar="/abs/path")
722
+ command.add_argument("--idempotency-key", help=argparse.SUPPRESS)
723
+
724
+ send_command = commands.add_parser(
725
+ "send",
726
+ help="send one message to a route; the Markdown body is read from stdin",
727
+ allow_abbrev=False,
728
+ )
729
+ message_send_options(send_command)
730
+ send_command.add_argument("--kind", choices=("message", "skill"), default="message")
731
+ send_command.add_argument(
732
+ "--in-reply-to", default=None, metavar="MESSAGE_ID",
733
+ help="reply to this message using its sender's frozen @@ route; no reply-all (message kind only)",
734
+ )
735
+ send_command.add_argument("--skill-slug", default=None)
736
+ send_command.add_argument("--summary", default=None)
737
+ send_command.add_argument("--tags", default=None, help="comma-separated tags")
738
+ send_command.add_argument("--change-note", default=None)
739
+ send_command.add_argument("--expected-version", type=int, default=None)
740
+ send_command.set_defaults(handler=send)
741
+
742
+ reply_command = commands.add_parser(
743
+ "reply",
744
+ help="reply to a message sender through this turn's frozen @@ sender route; no reply-all",
745
+ description="Reply only to the message sender using a frozen route from this turn's explicit @@ sender mention; no reply-all. The Markdown body is read from stdin.",
746
+ allow_abbrev=False,
747
+ )
748
+ reply_command.add_argument("in_reply_to", metavar="MESSAGE_ID")
749
+ message_send_options(reply_command)
750
+ reply_command.set_defaults(
751
+ handler=send, kind="message", skill_slug=None, summary=None, tags=None,
752
+ change_note=None, expected_version=None,
753
+ )
754
+ edit_command = commands.add_parser(
755
+ "edit", help="edit the body of one existing Bulletin message by exact ID and version",
756
+ description="Read MESSAGE_ID --include-revision first, then edit that exact Bulletin message through an authorized Bulletin route. The replacement Markdown body is read from stdin. Title, attachments, and skill data are preserved. Unsupported servers and stale versions fail without creating a new post.",
757
+ allow_abbrev=False,
758
+ )
759
+ edit_command.add_argument("message_id", metavar="MESSAGE_ID")
760
+ edit_command.add_argument("--route", required=True)
761
+ edit_command.add_argument("--expected-version", type=int, required=True)
762
+ edit_command.add_argument("--idempotency-key", help=argparse.SUPPRESS)
763
+ edit_command.set_defaults(handler=edit)
764
+ return root
765
+
766
+
767
+ def main(argv: list[str] | None = None) -> int:
768
+ previous_authority_file = os.environ.get(
769
+ "AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
770
+ )
771
+ try:
772
+ args = parser().parse_args(argv)
773
+ selected_authority = _selected_authority_path(args.authority_file)
774
+ os.environ["AGENTSDOCK_PROVIDER_AUTHORITY_FILE"] = str(
775
+ selected_authority
776
+ )
777
+ result = args.handler(args)
778
+ print(json.dumps(result, indent=2, sort_keys=True, ensure_ascii=False))
779
+ return 0
780
+ except TeamCLIError as exc:
781
+ print(f"agentsdock-team: {exc}", file=sys.stderr)
782
+ return 2
783
+ finally:
784
+ if previous_authority_file is None:
785
+ os.environ.pop("AGENTSDOCK_PROVIDER_AUTHORITY_FILE", None)
786
+ else:
787
+ os.environ[
788
+ "AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
789
+ ] = previous_authority_file
790
+
791
+
792
+ if __name__ == "__main__":
793
+ raise SystemExit(main())