@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,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())