@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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Chat-scoped scheduled-job CLI for AgentsDock agent turns."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import ipaddress
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
PROVIDER_RUNTIME_VALUE_MAX_BYTES = 4096
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JobsCLIError(RuntimeError):
|
|
23
|
+
"""A safe, user-facing CLI failure."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
27
|
+
def redirect_request(self, *_args: Any, **_kwargs: Any) -> None:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def host_is_loopback(host: str) -> bool:
|
|
32
|
+
try:
|
|
33
|
+
address = ipaddress.ip_address(host)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return host.lower() == "localhost"
|
|
36
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
|
37
|
+
address = address.ipv4_mapped
|
|
38
|
+
return address.is_loopback
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical_http_origin(value: str, label: str) -> tuple[str, bool]:
|
|
42
|
+
raw = value.strip()
|
|
43
|
+
try:
|
|
44
|
+
parsed = urllib.parse.urlsplit(raw)
|
|
45
|
+
port = parsed.port or 80
|
|
46
|
+
except ValueError as exc:
|
|
47
|
+
raise JobsCLIError(f"{label} must be an HTTP origin") from exc
|
|
48
|
+
if (
|
|
49
|
+
parsed.scheme.lower() != "http"
|
|
50
|
+
or not parsed.hostname
|
|
51
|
+
or parsed.username is not None
|
|
52
|
+
or parsed.password is not None
|
|
53
|
+
or parsed.path not in {"", "/"}
|
|
54
|
+
or parsed.query
|
|
55
|
+
or parsed.fragment
|
|
56
|
+
):
|
|
57
|
+
raise JobsCLIError(f"{label} must be an HTTP origin")
|
|
58
|
+
host = parsed.hostname.lower()
|
|
59
|
+
try:
|
|
60
|
+
address = ipaddress.ip_address(host)
|
|
61
|
+
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
|
|
62
|
+
address = address.ipv4_mapped
|
|
63
|
+
host = address.compressed
|
|
64
|
+
loopback = address.is_loopback
|
|
65
|
+
url_host = f"[{host}]" if isinstance(address, ipaddress.IPv6Address) else host
|
|
66
|
+
except ValueError:
|
|
67
|
+
loopback = host == "localhost"
|
|
68
|
+
url_host = host
|
|
69
|
+
return f"http://{url_host}:{port}", loopback
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def nonempty_chat_id(value: str) -> str:
|
|
73
|
+
chat_id = value.strip()
|
|
74
|
+
if not chat_id:
|
|
75
|
+
raise argparse.ArgumentTypeError("--chat-id must not be empty")
|
|
76
|
+
return chat_id
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def chat_route_selection(value: str) -> dict[str, str]:
|
|
80
|
+
"""Parse an opaque live route without accepting an internal chat id."""
|
|
81
|
+
|
|
82
|
+
raw = value.strip()
|
|
83
|
+
route_id, separator, action = raw.partition("=")
|
|
84
|
+
if not re.fullmatch(r"route_[0-9a-f]{32}", route_id):
|
|
85
|
+
raise argparse.ArgumentTypeError(
|
|
86
|
+
"--chat-route must use an opaque route ID returned by the chats helper"
|
|
87
|
+
)
|
|
88
|
+
selected_action = action.strip() if separator else "instruction"
|
|
89
|
+
if selected_action not in {"instruction", "request_reply"}:
|
|
90
|
+
raise argparse.ArgumentTypeError(
|
|
91
|
+
"--chat-route action must be instruction or request_reply"
|
|
92
|
+
)
|
|
93
|
+
return {"route_id": route_id, "action": selected_action}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def bounded_identity_value(value: str | None, label: str) -> str:
|
|
97
|
+
clean = str(value or "").strip()
|
|
98
|
+
try:
|
|
99
|
+
size = len(clean.encode("utf-8"))
|
|
100
|
+
except UnicodeEncodeError as exc:
|
|
101
|
+
raise JobsCLIError(f"{label} is not valid UTF-8") from exc
|
|
102
|
+
if size > PROVIDER_RUNTIME_VALUE_MAX_BYTES:
|
|
103
|
+
raise JobsCLIError(f"{label} exceeds the provider runtime limit")
|
|
104
|
+
return clean
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def selected_authority_path(authority_file: str | None = None) -> Path:
|
|
108
|
+
explicit = bounded_identity_value(authority_file, "--authority-file")
|
|
109
|
+
ambient = bounded_identity_value(
|
|
110
|
+
os.environ.get("AGENTSDOCK_PROVIDER_AUTHORITY_FILE"),
|
|
111
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE",
|
|
112
|
+
)
|
|
113
|
+
if explicit and ambient:
|
|
114
|
+
explicit_key = os.path.abspath(os.path.expanduser(explicit))
|
|
115
|
+
ambient_key = os.path.abspath(os.path.expanduser(ambient))
|
|
116
|
+
if explicit_key != ambient_key:
|
|
117
|
+
raise JobsCLIError(
|
|
118
|
+
"--authority-file conflicts with the live provider authority"
|
|
119
|
+
)
|
|
120
|
+
selected = explicit or ambient
|
|
121
|
+
if not selected:
|
|
122
|
+
raise JobsCLIError("--authority-file is required")
|
|
123
|
+
return Path(selected).expanduser()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def provider_authority(authority_file: str | None = None) -> tuple[str, str]:
|
|
127
|
+
path = selected_authority_path(authority_file)
|
|
128
|
+
try:
|
|
129
|
+
if path.stat().st_mode & 0o077:
|
|
130
|
+
raise JobsCLIError("authority file permissions are unsafe")
|
|
131
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
132
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
133
|
+
raise JobsCLIError(f"could not read authority file: {exc}") from exc
|
|
134
|
+
capability = str(payload.get("provider_capability") or payload.get("capability") or "")
|
|
135
|
+
source_session_id = str(payload.get("source_session_id") or "").strip()
|
|
136
|
+
if not capability or not source_session_id:
|
|
137
|
+
raise JobsCLIError("authority file is invalid")
|
|
138
|
+
return capability, source_session_id
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def authority_server_origin(authority_file: str | None = None) -> str:
|
|
142
|
+
path = selected_authority_path(authority_file)
|
|
143
|
+
try:
|
|
144
|
+
if path.stat().st_mode & 0o077:
|
|
145
|
+
raise JobsCLIError("authority file permissions are unsafe")
|
|
146
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
147
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
148
|
+
raise JobsCLIError(f"could not read authority file: {exc}") from exc
|
|
149
|
+
return bounded_identity_value(
|
|
150
|
+
payload.get("provider_server_origin"),
|
|
151
|
+
"authority provider_server_origin",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validated_server_url(authority_origin: str) -> str:
|
|
156
|
+
raw_server_url = os.environ.get("AGENTSDOCK_SERVER_URL", "").strip()
|
|
157
|
+
if not raw_server_url:
|
|
158
|
+
raise JobsCLIError("missing agent environment: AGENTSDOCK_SERVER_URL")
|
|
159
|
+
server_origin, loopback = canonical_http_origin(
|
|
160
|
+
raw_server_url,
|
|
161
|
+
"AGENTSDOCK_SERVER_URL",
|
|
162
|
+
)
|
|
163
|
+
runtime_origin = bounded_identity_value(
|
|
164
|
+
os.environ.get("AGENTSDOCK_PROVIDER_SERVER_ORIGIN"),
|
|
165
|
+
"AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
|
|
166
|
+
)
|
|
167
|
+
if runtime_origin:
|
|
168
|
+
canonical_runtime, _runtime_loopback = canonical_http_origin(
|
|
169
|
+
runtime_origin,
|
|
170
|
+
"AGENTSDOCK_PROVIDER_SERVER_ORIGIN",
|
|
171
|
+
)
|
|
172
|
+
if canonical_runtime != server_origin:
|
|
173
|
+
raise JobsCLIError(
|
|
174
|
+
"AGENTSDOCK_SERVER_URL conflicts with the live provider origin"
|
|
175
|
+
)
|
|
176
|
+
if loopback:
|
|
177
|
+
return raw_server_url.rstrip("/")
|
|
178
|
+
if not authority_origin:
|
|
179
|
+
raise JobsCLIError(
|
|
180
|
+
"non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
|
|
181
|
+
)
|
|
182
|
+
canonical_authority, _authority_loopback = canonical_http_origin(
|
|
183
|
+
authority_origin,
|
|
184
|
+
"authority provider_server_origin",
|
|
185
|
+
)
|
|
186
|
+
if canonical_authority != server_origin:
|
|
187
|
+
raise JobsCLIError(
|
|
188
|
+
"non-loopback AGENTSDOCK_SERVER_URL must match the authority origin"
|
|
189
|
+
)
|
|
190
|
+
return server_origin
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def required_environment() -> tuple[str, str, str]:
|
|
194
|
+
explicit_chat_id = bounded_identity_value(
|
|
195
|
+
os.environ.get("AGENTSDOCK_CHAT_ID"),
|
|
196
|
+
"AGENTSDOCK_CHAT_ID",
|
|
197
|
+
)
|
|
198
|
+
token, authority_chat_id = provider_authority()
|
|
199
|
+
server_url = validated_server_url(authority_server_origin())
|
|
200
|
+
if explicit_chat_id and explicit_chat_id != authority_chat_id:
|
|
201
|
+
raise JobsCLIError("--chat-id does not match the authority file")
|
|
202
|
+
chat_id = explicit_chat_id or authority_chat_id
|
|
203
|
+
return server_url, chat_id, token
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def api_request(method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
207
|
+
server_url, _chat_id, token = required_environment()
|
|
208
|
+
body = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
209
|
+
headers = {"Accept": "application/json"}
|
|
210
|
+
if body is not None:
|
|
211
|
+
headers["Content-Type"] = "application/json"
|
|
212
|
+
headers["X-AgentsDock-Provider-Capability"] = token
|
|
213
|
+
request = urllib.request.Request(f"{server_url}{path}", data=body, headers=headers, method=method)
|
|
214
|
+
opener = urllib.request.build_opener(
|
|
215
|
+
urllib.request.ProxyHandler({}),
|
|
216
|
+
NoRedirectHandler(),
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
with opener.open(request, timeout=30) as response:
|
|
220
|
+
decoded = json.loads(response.read().decode("utf-8"))
|
|
221
|
+
except urllib.error.HTTPError as exc:
|
|
222
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
223
|
+
try:
|
|
224
|
+
detail = json.loads(raw).get("detail") or raw
|
|
225
|
+
except json.JSONDecodeError:
|
|
226
|
+
detail = raw
|
|
227
|
+
raise JobsCLIError(f"server rejected request ({exc.code}): {detail or exc.reason}") from exc
|
|
228
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
229
|
+
reason = getattr(exc, "reason", exc)
|
|
230
|
+
raise JobsCLIError(f"could not reach AgentsServer: {reason}") from exc
|
|
231
|
+
except json.JSONDecodeError as exc:
|
|
232
|
+
raise JobsCLIError("AgentsServer returned invalid JSON") from exc
|
|
233
|
+
if not isinstance(decoded, dict):
|
|
234
|
+
raise JobsCLIError("AgentsServer returned an invalid response")
|
|
235
|
+
return decoded
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def safe_job_projection(job: dict[str, Any]) -> dict[str, Any]:
|
|
239
|
+
"""Defensively omit exact saved chat references from agent CLI output."""
|
|
240
|
+
|
|
241
|
+
projected = dict(job)
|
|
242
|
+
raw_references = projected.pop("chat_references", None)
|
|
243
|
+
if "chat_target_count" not in projected and isinstance(raw_references, list):
|
|
244
|
+
projected["chat_target_count"] = len(raw_references)
|
|
245
|
+
return projected
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
PROVIDER_PRIVATE_CROSS_CHAT_RUN_FIELDS = {
|
|
249
|
+
"chat_references",
|
|
250
|
+
"target_session_id",
|
|
251
|
+
"source_session_id",
|
|
252
|
+
"source_run_id",
|
|
253
|
+
"authorization_source_run_id",
|
|
254
|
+
"authorization_route_id",
|
|
255
|
+
"cross_chat_envelope_id",
|
|
256
|
+
"cross_chat_exchange_id",
|
|
257
|
+
"cross_chat_exchange_leg_id",
|
|
258
|
+
"exchange_id",
|
|
259
|
+
"exchange_leg_id",
|
|
260
|
+
"inbound_leg_id",
|
|
261
|
+
"envelope_id",
|
|
262
|
+
"leg_id",
|
|
263
|
+
"parent_leg_id",
|
|
264
|
+
"cross_chat_obligation_ids",
|
|
265
|
+
"cross_chat_exchange_ids",
|
|
266
|
+
"cross_chat_direct_message_ids",
|
|
267
|
+
"secure_peer_envelope_id",
|
|
268
|
+
"requester_session_id",
|
|
269
|
+
"responder_session_id",
|
|
270
|
+
"target_chat_id",
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def contains_private_chat_target_fields(value: Any) -> bool:
|
|
275
|
+
if isinstance(value, dict):
|
|
276
|
+
return any(
|
|
277
|
+
key in PROVIDER_PRIVATE_CROSS_CHAT_RUN_FIELDS
|
|
278
|
+
or (
|
|
279
|
+
key == "id"
|
|
280
|
+
and isinstance(nested, str)
|
|
281
|
+
and re.fullmatch(
|
|
282
|
+
r"(?:handoff|exchange|leg)_[A-Za-z0-9_-]+",
|
|
283
|
+
nested,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
or contains_private_chat_target_fields(nested)
|
|
287
|
+
for key, nested in value.items()
|
|
288
|
+
)
|
|
289
|
+
if isinstance(value, list):
|
|
290
|
+
return any(contains_private_chat_target_fields(nested) for nested in value)
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def scoped_jobs() -> list[dict[str, Any]]:
|
|
295
|
+
_server_url, chat_id, _token = required_environment()
|
|
296
|
+
encoded_chat_id = urllib.parse.quote(chat_id, safe="")
|
|
297
|
+
response = api_request("GET", f"/api/agent/sessions/{encoded_chat_id}/jobs")
|
|
298
|
+
jobs = response.get("jobs")
|
|
299
|
+
if not isinstance(jobs, list) or not all(isinstance(job, dict) for job in jobs):
|
|
300
|
+
raise JobsCLIError("AgentsServer returned an invalid jobs list")
|
|
301
|
+
foreign = [str(job.get("id") or "unknown") for job in jobs if job.get("session_id") != chat_id]
|
|
302
|
+
if foreign:
|
|
303
|
+
raise JobsCLIError("AgentsServer returned jobs outside the active chat scope")
|
|
304
|
+
return [safe_job_projection(job) for job in jobs]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def owned_job(job_id: str) -> dict[str, Any]:
|
|
308
|
+
for job in scoped_jobs():
|
|
309
|
+
if job.get("id") == job_id:
|
|
310
|
+
return job
|
|
311
|
+
raise JobsCLIError(f"job {job_id!r} does not exist in the active chat")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def checked_job(response: dict[str, Any]) -> dict[str, Any]:
|
|
315
|
+
_server_url, chat_id, _token = required_environment()
|
|
316
|
+
job = response.get("job")
|
|
317
|
+
if not isinstance(job, dict):
|
|
318
|
+
raise JobsCLIError("AgentsServer returned an invalid job")
|
|
319
|
+
if job.get("session_id") != chat_id:
|
|
320
|
+
raise JobsCLIError("AgentsServer returned a job outside the active chat scope")
|
|
321
|
+
return safe_job_projection(job)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def command_list(_args: argparse.Namespace) -> Any:
|
|
325
|
+
return {"jobs": scoped_jobs()}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def command_get(args: argparse.Namespace) -> Any:
|
|
329
|
+
return {"job": owned_job(args.job_id)}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def command_runs(args: argparse.Namespace) -> Any:
|
|
333
|
+
_server_url, chat_id, _token = required_environment()
|
|
334
|
+
owned_job(args.job_id)
|
|
335
|
+
encoded_chat_id = urllib.parse.quote(chat_id, safe="")
|
|
336
|
+
job_id = urllib.parse.quote(args.job_id, safe="")
|
|
337
|
+
query = urllib.parse.urlencode({
|
|
338
|
+
key: value
|
|
339
|
+
for key, value in (
|
|
340
|
+
("before_seq", args.before_seq),
|
|
341
|
+
("limit", args.limit),
|
|
342
|
+
)
|
|
343
|
+
if value is not None
|
|
344
|
+
})
|
|
345
|
+
path = f"/api/agent/sessions/{encoded_chat_id}/jobs/{job_id}/runs"
|
|
346
|
+
if query:
|
|
347
|
+
path += f"?{query}"
|
|
348
|
+
response = api_request("GET", path)
|
|
349
|
+
runs = response.get("runs")
|
|
350
|
+
if not isinstance(runs, list) or not all(
|
|
351
|
+
isinstance(run, dict) for run in runs
|
|
352
|
+
):
|
|
353
|
+
raise JobsCLIError("AgentsServer returned invalid job history")
|
|
354
|
+
if (
|
|
355
|
+
response.get("session_id") != chat_id
|
|
356
|
+
or response.get("job_id") != args.job_id
|
|
357
|
+
):
|
|
358
|
+
raise JobsCLIError("AgentsServer returned history outside the active chat scope")
|
|
359
|
+
if contains_private_chat_target_fields(response):
|
|
360
|
+
raise JobsCLIError("AgentsServer returned private chat target data in job history")
|
|
361
|
+
return response
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def command_create(args: argparse.Namespace) -> Any:
|
|
365
|
+
_server_url, chat_id, _token = required_environment()
|
|
366
|
+
if args.interval_seconds is None and args.cron is None and args.rrule is None and args.first_run_at is None:
|
|
367
|
+
raise JobsCLIError("create requires --interval-seconds, --cron, --rrule, or --first-run-at")
|
|
368
|
+
if args.interval_seconds is None and args.cron is None and args.rrule is None:
|
|
369
|
+
if args.loop or (args.max_runs is not None and args.max_runs != 1):
|
|
370
|
+
raise JobsCLIError("a one-time --first-run-at job cannot loop or run more than once without a schedule")
|
|
371
|
+
schedule_kind = "cron" if args.cron is not None else "rrule" if args.rrule is not None else "interval"
|
|
372
|
+
payload: dict[str, Any] = {
|
|
373
|
+
"title": args.title,
|
|
374
|
+
"prompt": args.prompt,
|
|
375
|
+
"schedule_kind": schedule_kind,
|
|
376
|
+
"interval_seconds": args.interval_seconds,
|
|
377
|
+
"cron_expression": args.cron,
|
|
378
|
+
"rrule": args.rrule,
|
|
379
|
+
"timezone": args.timezone,
|
|
380
|
+
"first_run_at": args.first_run_at,
|
|
381
|
+
"loop": args.loop,
|
|
382
|
+
"max_runs": args.max_runs,
|
|
383
|
+
"enabled": not args.disabled,
|
|
384
|
+
"backend": args.backend,
|
|
385
|
+
"context_mode": args.context_mode,
|
|
386
|
+
"chat_routes": list(args.chat_route or []),
|
|
387
|
+
}
|
|
388
|
+
encoded_chat_id = urllib.parse.quote(chat_id, safe="")
|
|
389
|
+
return {"job": checked_job(api_request("POST", f"/api/agent/sessions/{encoded_chat_id}/jobs", payload))}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def command_update(args: argparse.Namespace) -> Any:
|
|
393
|
+
_server_url, chat_id, _token = required_environment()
|
|
394
|
+
current_job = owned_job(args.job_id)
|
|
395
|
+
patch: dict[str, Any] = {}
|
|
396
|
+
for key in (
|
|
397
|
+
"title",
|
|
398
|
+
"prompt",
|
|
399
|
+
"interval_seconds",
|
|
400
|
+
"rrule",
|
|
401
|
+
"timezone",
|
|
402
|
+
"next_run_at",
|
|
403
|
+
"backend",
|
|
404
|
+
"context_mode",
|
|
405
|
+
):
|
|
406
|
+
value = getattr(args, key)
|
|
407
|
+
if value is not None:
|
|
408
|
+
patch[key] = value
|
|
409
|
+
target_kind = (
|
|
410
|
+
"interval" if args.interval_seconds is not None
|
|
411
|
+
else "cron" if args.cron is not None
|
|
412
|
+
else "rrule" if args.rrule is not None
|
|
413
|
+
else str(current_job.get("schedule_kind") or "interval")
|
|
414
|
+
)
|
|
415
|
+
if args.max_runs is not None:
|
|
416
|
+
patch["max_runs"] = args.max_runs
|
|
417
|
+
if target_kind == "interval":
|
|
418
|
+
patch["loop"] = False
|
|
419
|
+
elif args.unlimited:
|
|
420
|
+
patch["max_runs"] = None
|
|
421
|
+
if target_kind == "interval":
|
|
422
|
+
patch["loop"] = True
|
|
423
|
+
if args.cron is not None:
|
|
424
|
+
patch["cron_expression"] = args.cron
|
|
425
|
+
if args.interval_seconds is not None:
|
|
426
|
+
patch["schedule_kind"] = "interval"
|
|
427
|
+
elif args.cron is not None:
|
|
428
|
+
patch["schedule_kind"] = "cron"
|
|
429
|
+
elif args.rrule is not None:
|
|
430
|
+
patch["schedule_kind"] = "rrule"
|
|
431
|
+
if args.loop is not None:
|
|
432
|
+
patch["loop"] = args.loop
|
|
433
|
+
if args.enabled is not None:
|
|
434
|
+
patch["enabled"] = args.enabled
|
|
435
|
+
if args.chat_route is not None:
|
|
436
|
+
patch["chat_routes"] = list(args.chat_route)
|
|
437
|
+
elif args.clear_chat_routes:
|
|
438
|
+
patch["chat_routes"] = []
|
|
439
|
+
if not patch:
|
|
440
|
+
raise JobsCLIError("update requires at least one changed field")
|
|
441
|
+
encoded_chat_id = urllib.parse.quote(chat_id, safe="")
|
|
442
|
+
job_id = urllib.parse.quote(args.job_id, safe="")
|
|
443
|
+
return {
|
|
444
|
+
"job": checked_job(
|
|
445
|
+
api_request("PATCH", f"/api/agent/sessions/{encoded_chat_id}/jobs/{job_id}", patch)
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def command_delete(args: argparse.Namespace) -> Any:
|
|
451
|
+
_server_url, chat_id, _token = required_environment()
|
|
452
|
+
owned_job(args.job_id)
|
|
453
|
+
encoded_chat_id = urllib.parse.quote(chat_id, safe="")
|
|
454
|
+
job_id = urllib.parse.quote(args.job_id, safe="")
|
|
455
|
+
response = api_request("DELETE", f"/api/agent/sessions/{encoded_chat_id}/jobs/{job_id}")
|
|
456
|
+
if response.get("deleted") is not True:
|
|
457
|
+
raise JobsCLIError(f"job {args.job_id!r} was not deleted")
|
|
458
|
+
return {"ok": True, "deleted": True, "job_id": args.job_id}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
462
|
+
parser = argparse.ArgumentParser(
|
|
463
|
+
description="Manage scheduled jobs for the current AgentsDock chat.",
|
|
464
|
+
allow_abbrev=False,
|
|
465
|
+
)
|
|
466
|
+
parser.add_argument(
|
|
467
|
+
"--authority-file",
|
|
468
|
+
help="mode-0600 per-run AgentsDock provider authority file",
|
|
469
|
+
)
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--chat-id",
|
|
472
|
+
type=nonempty_chat_id,
|
|
473
|
+
help="explicit chat scope (defaults to AGENTSDOCK_CHAT_ID)",
|
|
474
|
+
)
|
|
475
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
476
|
+
|
|
477
|
+
list_parser = subparsers.add_parser(
|
|
478
|
+
"list", help="list jobs in the active chat", allow_abbrev=False
|
|
479
|
+
)
|
|
480
|
+
list_parser.set_defaults(handler=command_list)
|
|
481
|
+
|
|
482
|
+
get_parser = subparsers.add_parser(
|
|
483
|
+
"get", help="get one job in the active chat", allow_abbrev=False
|
|
484
|
+
)
|
|
485
|
+
get_parser.add_argument("job_id")
|
|
486
|
+
get_parser.set_defaults(handler=command_get)
|
|
487
|
+
|
|
488
|
+
runs_parser = subparsers.add_parser(
|
|
489
|
+
"runs", help="show recent run status for one job", allow_abbrev=False
|
|
490
|
+
)
|
|
491
|
+
runs_parser.add_argument("job_id")
|
|
492
|
+
runs_parser.add_argument("--before-seq", type=int)
|
|
493
|
+
runs_parser.add_argument("--limit", type=int, default=20)
|
|
494
|
+
runs_parser.set_defaults(handler=command_runs)
|
|
495
|
+
|
|
496
|
+
create_parser = subparsers.add_parser(
|
|
497
|
+
"create", help="create a job in the active chat", allow_abbrev=False
|
|
498
|
+
)
|
|
499
|
+
create_parser.add_argument("--title", required=True)
|
|
500
|
+
create_parser.add_argument("--prompt", required=True)
|
|
501
|
+
create_schedule = create_parser.add_mutually_exclusive_group()
|
|
502
|
+
create_schedule.add_argument("--interval-seconds", type=int)
|
|
503
|
+
create_schedule.add_argument("--cron", help="cron expression (seconds-first for 6-7 fields)")
|
|
504
|
+
create_schedule.add_argument("--rrule", help="RFC 5545 RRULE property")
|
|
505
|
+
create_parser.add_argument("--timezone", default="UTC", help="IANA timezone (default: UTC)")
|
|
506
|
+
create_parser.add_argument("--first-run-at", help="ISO-8601 timestamp for the first run")
|
|
507
|
+
create_parser.add_argument("--loop", action="store_true", help="repeat at the interval")
|
|
508
|
+
create_parser.add_argument("--max-runs", type=int)
|
|
509
|
+
create_parser.add_argument("--disabled", action="store_true")
|
|
510
|
+
create_parser.add_argument("--backend", choices=("codex", "claude", "cursor"))
|
|
511
|
+
create_parser.add_argument(
|
|
512
|
+
"--context-mode",
|
|
513
|
+
choices=("chat", "standalone"),
|
|
514
|
+
default="chat",
|
|
515
|
+
help="continue in the parent chat (default) or use a fresh provider context",
|
|
516
|
+
)
|
|
517
|
+
create_parser.add_argument(
|
|
518
|
+
"--chat-route",
|
|
519
|
+
action="append",
|
|
520
|
+
type=chat_route_selection,
|
|
521
|
+
metavar="ROUTE_ID[=ACTION]",
|
|
522
|
+
help=(
|
|
523
|
+
"persist an exact chat target from the current chats-helper list; "
|
|
524
|
+
"the prompt must contain that chat's exact @title (repeatable)"
|
|
525
|
+
),
|
|
526
|
+
)
|
|
527
|
+
create_parser.set_defaults(handler=command_create)
|
|
528
|
+
|
|
529
|
+
update_parser = subparsers.add_parser(
|
|
530
|
+
"update", help="update a job owned by the active chat", allow_abbrev=False
|
|
531
|
+
)
|
|
532
|
+
update_parser.add_argument("job_id")
|
|
533
|
+
update_parser.add_argument("--title")
|
|
534
|
+
update_parser.add_argument("--prompt")
|
|
535
|
+
update_schedule = update_parser.add_mutually_exclusive_group()
|
|
536
|
+
update_schedule.add_argument("--interval-seconds", type=int)
|
|
537
|
+
update_schedule.add_argument("--cron", help="cron expression (seconds-first for 6-7 fields)")
|
|
538
|
+
update_schedule.add_argument("--rrule", help="RFC 5545 RRULE property")
|
|
539
|
+
update_parser.add_argument("--timezone", help="IANA timezone")
|
|
540
|
+
update_parser.add_argument("--next-run-at", help="ISO-8601 timestamp for the next run")
|
|
541
|
+
run_limit_group = update_parser.add_mutually_exclusive_group()
|
|
542
|
+
run_limit_group.add_argument("--max-runs", type=int)
|
|
543
|
+
run_limit_group.add_argument("--unlimited", action="store_true", help="clear a finite run limit")
|
|
544
|
+
update_parser.add_argument("--backend", choices=("codex", "claude", "cursor"))
|
|
545
|
+
update_parser.add_argument(
|
|
546
|
+
"--context-mode",
|
|
547
|
+
choices=("chat", "standalone"),
|
|
548
|
+
)
|
|
549
|
+
loop_group = update_parser.add_mutually_exclusive_group()
|
|
550
|
+
loop_group.add_argument("--loop", dest="loop", action="store_true")
|
|
551
|
+
loop_group.add_argument("--no-loop", dest="loop", action="store_false")
|
|
552
|
+
enabled_group = update_parser.add_mutually_exclusive_group()
|
|
553
|
+
enabled_group.add_argument("--enable", dest="enabled", action="store_true")
|
|
554
|
+
enabled_group.add_argument("--disable", dest="enabled", action="store_false")
|
|
555
|
+
update_routes = update_parser.add_mutually_exclusive_group()
|
|
556
|
+
update_routes.add_argument(
|
|
557
|
+
"--chat-route",
|
|
558
|
+
action="append",
|
|
559
|
+
type=chat_route_selection,
|
|
560
|
+
metavar="ROUTE_ID[=ACTION]",
|
|
561
|
+
help=(
|
|
562
|
+
"replace persisted chat targets using opaque routes from the "
|
|
563
|
+
"current chats-helper list (repeatable)"
|
|
564
|
+
),
|
|
565
|
+
)
|
|
566
|
+
update_routes.add_argument(
|
|
567
|
+
"--clear-chat-routes",
|
|
568
|
+
action="store_true",
|
|
569
|
+
help="revoke every persisted chat target from this job",
|
|
570
|
+
)
|
|
571
|
+
update_parser.set_defaults(
|
|
572
|
+
handler=command_update,
|
|
573
|
+
loop=None,
|
|
574
|
+
enabled=None,
|
|
575
|
+
chat_route=None,
|
|
576
|
+
clear_chat_routes=False,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
delete_parser = subparsers.add_parser(
|
|
580
|
+
"delete", help="delete a job owned by the active chat", allow_abbrev=False
|
|
581
|
+
)
|
|
582
|
+
delete_parser.add_argument("job_id")
|
|
583
|
+
delete_parser.set_defaults(handler=command_delete)
|
|
584
|
+
return parser
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def main(argv: list[str] | None = None) -> int:
|
|
588
|
+
parser = build_parser()
|
|
589
|
+
args = parser.parse_args(argv)
|
|
590
|
+
previous_authority_file = os.environ.get(
|
|
591
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
|
|
592
|
+
)
|
|
593
|
+
previous_chat_id = os.environ.get("AGENTSDOCK_CHAT_ID")
|
|
594
|
+
try:
|
|
595
|
+
try:
|
|
596
|
+
authority_path = selected_authority_path(args.authority_file)
|
|
597
|
+
_token, authority_chat_id = provider_authority(args.authority_file)
|
|
598
|
+
explicit_chat_id = bounded_identity_value(args.chat_id, "--chat-id")
|
|
599
|
+
environment_chat_id = bounded_identity_value(
|
|
600
|
+
previous_chat_id,
|
|
601
|
+
"AGENTSDOCK_CHAT_ID",
|
|
602
|
+
)
|
|
603
|
+
if (
|
|
604
|
+
explicit_chat_id
|
|
605
|
+
and environment_chat_id
|
|
606
|
+
and explicit_chat_id != environment_chat_id
|
|
607
|
+
):
|
|
608
|
+
raise JobsCLIError(
|
|
609
|
+
"--chat-id conflicts with AGENTSDOCK_CHAT_ID"
|
|
610
|
+
)
|
|
611
|
+
for candidate in (explicit_chat_id, environment_chat_id):
|
|
612
|
+
if candidate and candidate != authority_chat_id:
|
|
613
|
+
raise JobsCLIError(
|
|
614
|
+
"--chat-id does not match the authority file"
|
|
615
|
+
)
|
|
616
|
+
os.environ["AGENTSDOCK_PROVIDER_AUTHORITY_FILE"] = str(
|
|
617
|
+
authority_path
|
|
618
|
+
)
|
|
619
|
+
os.environ["AGENTSDOCK_CHAT_ID"] = authority_chat_id
|
|
620
|
+
result = args.handler(args)
|
|
621
|
+
except JobsCLIError as exc:
|
|
622
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
623
|
+
return 1
|
|
624
|
+
finally:
|
|
625
|
+
if previous_authority_file is None:
|
|
626
|
+
os.environ.pop("AGENTSDOCK_PROVIDER_AUTHORITY_FILE", None)
|
|
627
|
+
else:
|
|
628
|
+
os.environ[
|
|
629
|
+
"AGENTSDOCK_PROVIDER_AUTHORITY_FILE"
|
|
630
|
+
] = previous_authority_file
|
|
631
|
+
if previous_chat_id is None:
|
|
632
|
+
os.environ.pop("AGENTSDOCK_CHAT_ID", None)
|
|
633
|
+
else:
|
|
634
|
+
os.environ["AGENTSDOCK_CHAT_ID"] = previous_chat_id
|
|
635
|
+
print(json.dumps(result, indent=2, sort_keys=True))
|
|
636
|
+
return 0
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
if __name__ == "__main__":
|
|
640
|
+
raise SystemExit(main())
|