netizen-cli 0.10.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,535 @@
1
+ """The single scheduled-plan MCP tool, on the owning application's loop.
2
+
3
+ MCP framing, initialization and Streamable HTTP are owned by the official SDK.
4
+ This adapter supplies only the management schema, call identity and a bounded
5
+ loopback listener. It never reads or writes Codex configuration files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import secrets
15
+ import socket
16
+ from collections.abc import Awaitable, Callable
17
+ from typing import Annotated, Any, Literal
18
+
19
+ import uvicorn
20
+ from mcp.server import Server, ServerRequestContext
21
+ from mcp.server.transport_security import TransportSecuritySettings
22
+ from mcp.types import (
23
+ CallToolRequestParams,
24
+ CallToolResult,
25
+ ListToolsResult,
26
+ PaginatedRequestParams,
27
+ TextContent,
28
+ Tool,
29
+ ToolAnnotations,
30
+ )
31
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
32
+
33
+
34
+ logger = logging.getLogger(__name__)
35
+ ManagementCallback = Callable[[dict[str, Any], str | None], Awaitable[dict[str, Any]]]
36
+ MAX_BODY_BYTES = 65536
37
+ CALL_TIMEOUT_SECONDS = 15.0
38
+ HTTP_TIMEOUT_SECONDS = 20.0
39
+ _REQUIRED_FIELDS = {
40
+ "view": ("plan_id",),
41
+ "create": ("name", "instructions", "schedule", "request_id"),
42
+ "update": ("plan_id", "expected_revision", "request_id"),
43
+ "delete": ("plan_id", "expected_revision", "request_id"),
44
+ "run_now": ("plan_id", "expected_revision", "request_id"),
45
+ "runs": ("plan_id",),
46
+ }
47
+ _SCHEDULE_REQUIRED = {
48
+ "once": ("at",), "daily": ("at",),
49
+ "weekly": ("at", "weekdays"), "interval": ("every_minutes",),
50
+ }
51
+ CREATE_EXAMPLE = {
52
+ "mode": "create", "name": "Daily check", "instructions": "Report the current project status.",
53
+ "schedule": {"kind": "daily", "at": "09:00"}, "timezone": "Asia/Shanghai",
54
+ "enabled": False, "request_id": "6d2c05a2-5da6-4f57-a433-c1ea5fbb2a6f",
55
+ }
56
+ RUN_NOW_EXAMPLE = {
57
+ "mode": "run_now", "plan_id": "<exact plan_id from list/view>",
58
+ "expected_revision": 1, "request_id": "6d2c05a2-5da6-4f57-a433-c1ea5fbb2a6f",
59
+ }
60
+ _RUN_NOW_FIELDS = frozenset({"mode", "plan_id", "expected_revision", "request_id"})
61
+
62
+ INSTRUCTIONS = """Manage Netizen scheduled tasks with cron_manage: create, list, view, update, pause, enable, delete, or run an existing plan now for recurring work, reminders, monitoring, and follow-ups.
63
+
64
+ When the user asks to manage scheduled tasks, use cron_manage and follow its schema. If the tool is deferred, find it through the available tool search first.
65
+
66
+ Plans default to target_kind=new_topic: each occurrence starts an independent persistent Codex Thread in a new Feishu topic. For follow-ups in the same conversation, choose target_kind=binding: each occurrence submits an input to the exact original Binding, starting a Turn or steering the running Turn through ordinary message handling. Omit target_binding_id to use the calling ordinary Thread's Binding. Switching away or archiving suspends automatic execution; restoring the target resumes future times without catch-up. Deleting the Binding deletes its plans. Pausing prevents automatic claims; deleting prevents all new claims. An already claimed occurrence may continue. Stop or continue work through ordinary conversation controls. Save clear task instructions in the plan.
67
+ When asked to run an existing plan once now, use run_now. Enabling a plan uses update and waits for its next scheduled time; changing its recurring time also uses update. Ordinary work without an existing-plan context remains an ordinary task.
68
+ """
69
+
70
+ TOOL_DESCRIPTION = """Manage Netizen scheduled tasks with cron_manage: options, list, view, create, update (including pause/enable), delete, run_now, and recent runs. Follow the schema and report success only from the returned result.
71
+ Find candidates by name before using an exact plan_id; clarify ambiguous matches. Resolve relative times into a structured schedule and IANA timezone, and save reusable instructions with explicit resource references. target_kind=new_topic (default) starts an independent ordinary Thread in a new Feishu topic. target_kind=binding submits to the exact original Binding and shares its context, current settings and ordinary start/steer behavior. For requests such as 'continue here in ten minutes', use binding. Pausing prevents automatic claims; deleting prevents all new claims. Already claimed work may continue and uses ordinary stop/continue controls.
72
+ For create/update/delete/run_now supply a UUID request_id; retry the same request with the same ID after a timeout. For update/delete/run_now copy the exact current revision from the latest list/view/create/update result into expected_revision; never increment it yourself. An existing current result does not require another view call. Omitted chat_id/project on create use the calling native Thread's Binding: the current Feishu conversation may be a group or a private chat; a topic uses its containing conversation. list defaults to that current conversation; all=true lists this instance's plans. update keeps omitted fields unchanged. No native Thread identity belongs in tool arguments.
73
+ run_now executes the saved plan once, including manually paused or ended plans, without changing its schedule, next due time or enabled state. Supply only mode, plan_id, expected_revision and request_id; instructions and destination come from that saved version. The result is an acceptance receipt with run_id and current run information, not proof of completion. Results go to the saved target; share the returned feishu_url when available. For new_topic an unfinished or uncertain previous initial Turn blocks another run. For binding, started/steered records prove input acceptance only: subsequent scheduled inputs remain independent even after an unknown outcome, while ordinary Runtime admission still applies. Inactive targets cannot run_now; this never switches or restores a conversation. Inspect view/runs when blocked; do not rewrite or duplicate the plan or execute its instructions yourself as a workaround. After a management timeout, reuse the original request_id; never generate a new one merely to retry an uncertain trigger.
74
+ For binding, omitted target_binding_id uses the calling ordinary Thread's exact Binding; Side or unmapped callers must supply an exact Binding ID returned by options. Native Thread IDs are never tool arguments. The target identity is immutable after creation. Do not supply project, chat_id or session_settings for binding: these follow the target conversation at execution time. enabled remains the user's pause preference; suspended_reason separately reports automatic suspension, and can_run_now reports target eligibility. options supplies source_binding_id and binding_targets; list may filter target_kind/target_binding_id.
75
+ For new_topic, create copies the calling Binding's session settings once; later Binding changes do not change the plan. session_settings is a partial override: turn_settings=null explicitly inherits native Codex settings; a model override supplies all three IDs. Other fields control reaction pulse, progress card and message context. Use options to discover current model/effort/service-tier IDs and defaults when choosing settings; ordinary creation needs no preliminary options call. A private target supports only current-only; an inherited catch-up default is normalized, but explicitly requesting catch-up is rejected. Updating unrelated fields retains the plan's settings even if its model is no longer available.
76
+ Recurring daily/weekly/interval schedules have no cutoff by default. Optional schedule.end_at is an inclusive cutoff timestamp with an explicit UTC offset and minute precision; turn a requested cutoff date into a specific time and timezone. Once schedules cannot use end_at.
77
+ Providing schedule on update replaces the whole rule: include unchanged kind/time/timezone and end_at to keep them. To remove end_at, omit it or set it to null in the replacement schedule. Omitting schedule entirely keeps the existing rule and cutoff.
78
+ Use the exact fields mode, instructions and schedule.kind. After invalid_request, correct the indicated fields and call cron_manage again; no shell commands or resource listing are needed to repair arguments.
79
+ Correct paused-create example (replace the request_id for a new request):
80
+ """ + json.dumps(CREATE_EXAMPLE)
81
+
82
+
83
+ class _ScheduleArguments(BaseModel):
84
+ model_config = ConfigDict(extra="forbid", strict=True, json_schema_extra={
85
+ "allOf": [
86
+ {"if": {"properties": {"kind": {"const": kind}}}, "then": {
87
+ "required": list(fields),
88
+ "properties": {field: {"not": {"type": "null"}, **({"minItems": 1} if field == "weekdays" else {})} for field in fields},
89
+ }}
90
+ for kind, fields in _SCHEDULE_REQUIRED.items()
91
+ ] + [{"if": {"properties": {"kind": {"const": "once"}}}, "then": {
92
+ "properties": {"end_at": {"type": "null"}},
93
+ }}],
94
+ })
95
+
96
+ kind: Literal["once", "daily", "weekly", "interval"]
97
+ timezone: str | None = Field(default=None, min_length=1, description="IANA timezone; may instead be supplied in the outer timezone field. If both exist they must match.")
98
+ at: str | None = Field(default=None, min_length=1, description="Required for once/daily/weekly. once: ISO timestamp with explicit UTC offset, to minute precision, e.g. 2030-01-02T09:00+08:00; daily/weekly: HH:mm. Omit for interval.")
99
+ weekdays: list[Annotated[int, Field(ge=0, le=6)]] | None = Field(default=None, description="Required nonempty list for weekly: Monday=0 through Sunday=6. Omit for other kinds.")
100
+ every_minutes: int | None = Field(default=None, ge=1, description="Required positive integer for interval. Omit for other kinds.")
101
+ anchor: int | float | None = Field(default=None, allow_inf_nan=False, description="Interval UTC epoch anchor. The service generates this; omit it when creating a plan.")
102
+ end_at: str | None = Field(default=None, min_length=1, description="Recurring kinds only. Inclusive cutoff as an ISO timestamp with explicit UTC offset and minute precision, e.g. 2030-01-31T23:59+08:00. Resolve date-only requests to a specific time. Omit/null means no cutoff in a replacement schedule.")
103
+
104
+ @model_validator(mode="after")
105
+ def required_for_kind(self) -> _ScheduleArguments:
106
+ if any(getattr(self, key) is None for key in _SCHEDULE_REQUIRED[self.kind]):
107
+ raise ValueError("Required schedule fields are missing.")
108
+ if self.kind == "weekly" and not self.weekdays:
109
+ raise ValueError("Weekly schedule requires weekdays.")
110
+ if self.kind == "once" and self.end_at is not None:
111
+ raise ValueError("Only recurring schedules support end_at.")
112
+ return self
113
+
114
+
115
+ class _TurnSettingsArguments(BaseModel):
116
+ model_config = ConfigDict(extra="forbid", strict=True)
117
+
118
+ model_id: str = Field(min_length=1)
119
+ effort_id: str = Field(min_length=1)
120
+ service_tier_id: str = Field(min_length=1)
121
+
122
+
123
+ class _SessionSettingsArguments(BaseModel):
124
+ model_config = ConfigDict(extra="forbid", strict=True)
125
+
126
+ turn_settings: _TurnSettingsArguments | None = Field(default=None, description="Omit to keep copied/current model settings; null explicitly inherits native Codex; an object must provide all three IDs returned by options.")
127
+ reaction_pulse_enabled: bool = False
128
+ progress_card_enabled: bool = True
129
+ completion_mention_enabled: bool = True
130
+ message_context_mode: Literal["current-only", "catch-up"] = "current-only"
131
+
132
+
133
+ class _Arguments(BaseModel):
134
+ model_config = ConfigDict(extra="forbid", strict=True)
135
+
136
+ mode: Literal["options", "list", "view", "create", "update", "delete", "run_now", "runs"]
137
+ plan_id: str | None = Field(default=None, min_length=1)
138
+ name: str | None = Field(default=None, min_length=1)
139
+ instructions: str | None = Field(default=None, min_length=1)
140
+ project: str | None = Field(default=None, min_length=1)
141
+ chat_id: str | None = Field(default=None, min_length=1)
142
+ target_kind: Literal["new_topic", "binding"] | None = Field(default=None, description="Create/list: new_topic (default) starts an independent topic and Thread; binding submits an input to the original conversation, starting or steering through ordinary message handling. Immutable after create.")
143
+ target_binding_id: str | None = Field(default=None, min_length=1, description="Exact Binding ID from options for binding targets or list filtering. Omit on binding create to use the calling ordinary Thread's Binding. Never supply a native Thread ID. Immutable after create.")
144
+ binding_query: str | None = Field(default=None, max_length=200, description="options only: filter available original conversations by partial Binding ID, Project alias, chat ID or topic ID. Use a narrower query when bindings_truncated is true; empty text clears the filter.")
145
+ schedule: _ScheduleArguments | None = Field(
146
+ default=None,
147
+ description=(
148
+ "Object with kind: once/daily/weekly/interval. once uses at as an ISO "
149
+ "timestamp with offset, to minute precision; daily/weekly use at='HH:mm'. "
150
+ "weekly also requires weekdays (Monday=0 through Sunday=6). interval uses "
151
+ "every_minutes (positive integer); the service generates anchor, omit it. "
152
+ "timezone is an IANA name, provided here or in the outer timezone field; "
153
+ "if both are present they must match. Recurring kinds optionally accept "
154
+ "end_at (inclusive offset ISO cutoff). On update this replaces the "
155
+ "whole rule: include fields/cutoff to retain; omit/null end_at "
156
+ "to clear the cutoff. Omit schedule entirely to preserve it. No cron/RRULE expressions."
157
+ ),
158
+ )
159
+ timezone: str | None = Field(default=None, min_length=1, description="IANA timezone, e.g. Asia/Shanghai.")
160
+ enabled: bool | None = None
161
+ session_settings: _SessionSettingsArguments = Field(default=None, description="Optional partial session settings. Omitted fields copy the source Binding at creation or keep the plan's existing values on update. Explicit turn_settings=null resets model settings to native inheritance.")
162
+ expected_revision: int | None = Field(default=None, ge=1, description="Copy the exact current revision from the latest list/view/create/update result. Do not increment it; no extra view call is needed if already known.")
163
+ request_id: str | None = Field(default=None, min_length=1, description="Stable UUID for mutation retries.")
164
+ cursor: str | None = None
165
+ limit: int | None = Field(default=None, ge=1, le=50, description="Page size for list/runs: 1 to 50; defaults to 20.")
166
+ all: bool | None = None
167
+ ended: bool | None = None
168
+
169
+ @model_validator(mode="after")
170
+ def required_for_mode(self) -> _Arguments:
171
+ required = _REQUIRED_FIELDS.get(self.mode, ())
172
+ if any(getattr(self, key) is None for key in required):
173
+ raise ValueError("Required fields are missing for this mode.")
174
+ if self.mode == "run_now" and self.model_fields_set - _RUN_NOW_FIELDS:
175
+ raise ValueError("run_now only accepts exact plan identity, revision and request_id.")
176
+ return self
177
+
178
+
179
+ def _tool_schema() -> dict[str, Any]:
180
+ schema = _Arguments.model_json_schema()
181
+ # Keep the advertised conditional requirements identical to validation.
182
+ schema["allOf"] = [
183
+ {"if": {"properties": {"mode": {"const": mode}}}, "then": {
184
+ "required": list(fields), "properties": {field: {"not": {"type": "null"}} for field in fields},
185
+ }}
186
+ for mode, fields in _REQUIRED_FIELDS.items()
187
+ ]
188
+ schema["allOf"].append({
189
+ "if": {"properties": {"mode": {"const": "run_now"}}},
190
+ "then": {"properties": {
191
+ field: False for field in schema["properties"] if field not in _RUN_NOW_FIELDS
192
+ }},
193
+ })
194
+ return schema
195
+
196
+
197
+ _FIELD_HINTS = {
198
+ "mode": "Set mode to options, list, view, create, update, delete, run_now or runs.",
199
+ "plan_id": "Supply the exact nonempty plan_id returned by list or create.",
200
+ "name": "Supply a nonempty plan name.",
201
+ "instructions": "Supply nonempty reusable task instructions in instructions.",
202
+ "project": "Supply a nonempty Project alias. Omit on create to use the current Binding's Project; omit on update to keep the plan's Project.",
203
+ "chat_id": "Supply a nonempty Feishu conversation ID. Omit on create to use the current conversation (group or private chat); omit on update to keep the plan's target.",
204
+ "target_kind": "Use new_topic for independent topic executions or binding for inputs to the original conversation. The target type is immutable after create.",
205
+ "target_binding_id": "Supply an exact Binding ID returned by options. Omit on binding create to use the current ordinary Binding; Side/unmapped callers must specify it. Never supply a native Thread ID.",
206
+ "binding_query": "For options, supply at most 200 characters matching a Binding ID, Project alias, chat ID or topic ID; empty text clears the filter.",
207
+ "schedule": "Supply a structured schedule with kind and its required fields: once/daily use at; weekly uses at and weekdays; interval uses every_minutes. Remove unsupported schedule fields.",
208
+ "timezone": "Supply an IANA timezone string, such as Asia/Shanghai.",
209
+ "enabled": "Use the boolean true or false.",
210
+ "expected_revision": "Copy the exact current integer revision (at least 1) from the latest list/view/create/update result. Do not increment it or call view again if already known.",
211
+ "request_id": "Supply a nonempty stable UUID request_id for this mutation.",
212
+ "cursor": "Use the cursor string returned by the previous page, or omit it for the first page.",
213
+ "limit": "Use an integer page size from 1 to 50 for list/runs, or omit it to use 20.",
214
+ "all": "Use the boolean true or false; true lists plans across this instance.",
215
+ "ended": "Use the boolean true or false to filter ended plans in list.",
216
+ "session_settings": "Supply a partial object containing only turn_settings, reaction_pulse_enabled, progress_card_enabled, completion_mention_enabled and message_context_mode. Omitted fields keep their defaults/current values.",
217
+ "session_settings.turn_settings": "Use null to inherit native Codex, or supply all three nonempty model_id, effort_id and service_tier_id from options.",
218
+ "session_settings.turn_settings.model_id": "Supply the exact nonempty model_id from options and all three model-setting IDs.",
219
+ "session_settings.turn_settings.effort_id": "Supply a supported nonempty effort_id from options and all three model-setting IDs.",
220
+ "session_settings.turn_settings.service_tier_id": "Supply a supported nonempty service_tier_id from options and all three model-setting IDs.",
221
+ "session_settings.reaction_pulse_enabled": "Use the boolean true or false for reaction pulse.",
222
+ "session_settings.completion_mention_enabled": "Use the boolean true or false to mention the human task initiator at completion. Automatic scheduled first turns have no human initiator and do not mention anyone.",
223
+ "session_settings.progress_card_enabled": "Use the boolean true or false for progress cards.",
224
+ "session_settings.message_context_mode": "Use current-only or catch-up. Private chat targets support only current-only.",
225
+ "schedule.kind": "Set schedule.kind to once, daily, weekly or interval.",
226
+ "schedule.timezone": "Supply an IANA timezone string matching the outer timezone, or omit this field.",
227
+ "schedule.at": "Supply at: once needs an ISO timestamp with explicit offset and minute precision; daily/weekly need HH:mm.",
228
+ "schedule.weekdays": "Supply a nonempty list of integers from 0 (Monday) to 6 (Sunday) for weekly.",
229
+ "schedule.every_minutes": "Supply a positive integer every_minutes for interval.",
230
+ "schedule.anchor": "Omit anchor so the service generates it, or supply a finite UTC epoch number for interval.",
231
+ "schedule.end_at": "For daily/weekly/interval, supply an inclusive ISO cutoff timestamp with explicit offset and minute precision. Remove it for once; omit/null it in a replacement schedule to clear the cutoff.",
232
+ "action": "Remove action; use mode instead.", "op": "Remove op; use mode instead.",
233
+ "instruction": "Remove instruction; use instructions instead.", "prompt": "Remove prompt; use instructions instead.",
234
+ "schedule.type": "Remove schedule.type; use schedule.kind instead.",
235
+ "schedule.run_at": "Remove schedule.run_at; use schedule.at instead.",
236
+ "schedule_type": "Remove schedule_type; use schedule.kind instead.",
237
+ "run_at": "Remove run_at; use schedule.at instead.",
238
+ "arguments": "Remove unsupported fields and use only the fields in the cron_manage schema.",
239
+ }
240
+
241
+
242
+ def _invalid_arguments(arguments: Any, error: ValidationError) -> CallToolResult:
243
+ # Never serialize Pydantic's input, message or context: unknown field names
244
+ # and literal/type errors can themselves contain credentials or task text.
245
+ fields: dict[str, str] = {}
246
+ for issue in error.errors(include_input=False, include_context=False, include_url=False):
247
+ location = issue["loc"]
248
+ path = ".".join(str(part) for part in location[:3]) if location[:1] == ("session_settings",) else ".".join(str(part) for part in location[:2]) if location[:1] == ("schedule",) else str(location[0]) if location else "arguments"
249
+ if path not in _FIELD_HINTS:
250
+ path = location[0] if location[:1] in (("schedule",), ("session_settings",)) else "arguments"
251
+ fields[path] = _FIELD_HINTS[path]
252
+ if isinstance(arguments, dict):
253
+ mode = arguments.get("mode")
254
+ for field in _REQUIRED_FIELDS.get(mode, ()) if isinstance(mode, str) else ():
255
+ if arguments.get(field) is None:
256
+ fields[field] = _FIELD_HINTS[field]
257
+ schedule = arguments.get("schedule")
258
+ kind = schedule.get("kind") if isinstance(schedule, dict) else None
259
+ for field in _SCHEDULE_REQUIRED.get(kind, ()) if isinstance(kind, str) else ():
260
+ if schedule.get(field) is None or (field == "weekdays" and not schedule.get(field)):
261
+ fields["schedule." + field] = _FIELD_HINTS["schedule." + field]
262
+ if kind == "once" and schedule.get("end_at") is not None:
263
+ fields["schedule.end_at"] = _FIELD_HINTS["schedule.end_at"]
264
+ manual = isinstance(arguments, dict) and arguments.get("mode") == "run_now"
265
+ if manual and set(arguments) - _RUN_NOW_FIELDS:
266
+ fields["arguments"] = "run_now only accepts mode, plan_id, expected_revision and request_id. Remove all other fields; it uses the saved plan without overrides."
267
+ return _result({"ok": False, "error": {
268
+ "code": "invalid_request",
269
+ "message": "Correct the fields below and retry cron_manage, keeping the requested plan details and request_id. Do not use shell commands or list_mcp_resources to discover argument names.",
270
+ "fields": [{"field": field, "message": hint} for field, hint in fields.items()],
271
+ **({"run_now_example": RUN_NOW_EXAMPLE} if manual else {"create_example": CREATE_EXAMPLE}),
272
+ }})
273
+
274
+
275
+ def _result(value: dict[str, Any]) -> CallToolResult:
276
+ return CallToolResult(
277
+ content=[TextContent(type="text", text=json.dumps(value, ensure_ascii=False))],
278
+ structured_content=value,
279
+ is_error=value.get("ok") is False or bool(value.get("error")),
280
+ )
281
+
282
+
283
+ def _error(code: str, message: str) -> CallToolResult:
284
+ return _result({"ok": False, "error": {"code": code, "message": message}})
285
+
286
+
287
+ def _native_thread_id(meta: dict[str, Any] | None) -> str | None:
288
+ """Read only native per-call metadata, never arguments or connection state."""
289
+ if meta is None:
290
+ return None
291
+ thread_id = meta.get("threadId")
292
+ turn_metadata = meta.get("x-codex-turn-metadata")
293
+ if isinstance(turn_metadata, str):
294
+ turn_metadata = json.loads(turn_metadata)
295
+ if turn_metadata is not None and not isinstance(turn_metadata, dict):
296
+ raise ValueError("Invalid native turn metadata")
297
+ other_id = (turn_metadata or {}).get("thread_id")
298
+ for value in (thread_id, other_id):
299
+ if value is not None and (not isinstance(value, str) or not value.strip()):
300
+ raise ValueError("Invalid native Thread ID")
301
+ if thread_id is not None and other_id is not None and thread_id != other_id:
302
+ raise ValueError("Conflicting native Thread IDs")
303
+ # The second metadata form is a cross-check, not an alternative source.
304
+ return thread_id
305
+
306
+
307
+ class _EmbeddedServer(uvicorn.Server):
308
+ def capture_signals(self):
309
+ # The Netizen process already owns signal handling and shutdown.
310
+ return contextlib.nullcontext()
311
+
312
+
313
+ class ScheduleMcpRunner:
314
+ """Bind before AsyncCodex creation; open management only after app readiness.
315
+
316
+ ``drain`` accepts an absolute event-loop deadline. ``close`` is idempotent
317
+ and also closes the listener with bounded ASGI lifespan shutdown.
318
+ """
319
+
320
+ def __init__(self) -> None:
321
+ nonce = secrets.token_hex(12)
322
+ self.namespace = f"netizen_scheduler_{nonce}"
323
+ self._env_key = f"NETIZEN_SCHEDULE_MCP_TOKEN_{nonce.upper()}"
324
+ self._token = secrets.token_urlsafe(32)
325
+ self._callback: ManagementCallback | None = None
326
+ self._admission = False
327
+ self._closed = False
328
+ self._loop: asyncio.AbstractEventLoop | None = None
329
+ self._http: _EmbeddedServer | None = None
330
+ self._server_task: asyncio.Task[None] | None = None
331
+ self._calls: set[asyncio.Task[Any]] = set()
332
+ self._url: str | None = None
333
+ self._mcp = Server(
334
+ "Netizen scheduled tasks",
335
+ instructions=INSTRUCTIONS,
336
+ on_list_tools=self._list_tools,
337
+ on_call_tool=self._call_tool,
338
+ )
339
+
340
+ @property
341
+ def url(self) -> str:
342
+ if self._url is None:
343
+ raise RuntimeError("Schedule MCP listener has not been bound")
344
+ return self._url
345
+
346
+ @property
347
+ def config_overrides(self) -> tuple[str, ...]:
348
+ # A single owned table entry cannot replace the user's mcp_servers table.
349
+ entry = (
350
+ f'{{url={json.dumps(self.url)}, '
351
+ f'bearer_token_env_var={json.dumps(self._env_key)}, '
352
+ 'enabled=true, enabled_tools=["cron_manage"]}'
353
+ )
354
+ return (f"mcp_servers.{self.namespace}={entry}",)
355
+
356
+ @property
357
+ def app_server_env(self) -> dict[str, str]:
358
+ return {self._env_key: self._token}
359
+
360
+ def attach(self, callback: ManagementCallback) -> None:
361
+ if self._closed or self._callback is not None:
362
+ raise RuntimeError("Schedule MCP callback is already attached or closed")
363
+ if not callable(callback):
364
+ raise TypeError("Schedule management callback must be callable")
365
+ self._callback = callback
366
+
367
+ def open_admission(self) -> None:
368
+ self._assert_loop()
369
+ if self._closed or self._callback is None or self._server_task is None or self._server_task.done():
370
+ raise RuntimeError("Schedule MCP is not ready")
371
+ self._admission = True
372
+
373
+ def close_admission(self) -> None:
374
+ self._admission = False
375
+
376
+ async def bind(self) -> None:
377
+ if self._closed or self._server_task is not None:
378
+ raise RuntimeError("Schedule MCP listener cannot be bound twice")
379
+ self._loop = asyncio.get_running_loop()
380
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
381
+ try:
382
+ listener.setblocking(False)
383
+ listener.bind(("127.0.0.1", 0))
384
+ port = listener.getsockname()[1]
385
+ self._url = f"http://127.0.0.1:{port}/mcp"
386
+ app = self._mcp.streamable_http_app(
387
+ json_response=True,
388
+ stateless_http=True,
389
+ max_request_body_size=MAX_BODY_BYTES,
390
+ transport_security=TransportSecuritySettings(
391
+ allowed_hosts=[f"127.0.0.1:{port}"],
392
+ allowed_origins=[],
393
+ ),
394
+ )
395
+ self._http = _EmbeddedServer(uvicorn.Config(
396
+ self._guard(app), host="127.0.0.1", port=port,
397
+ loop="asyncio", http="h11", ws="none", lifespan="on",
398
+ log_config=None, access_log=False, proxy_headers=False,
399
+ server_header=False, limit_concurrency=32,
400
+ h11_max_incomplete_event_size=32768,
401
+ timeout_keep_alive=5, timeout_graceful_shutdown=5,
402
+ ))
403
+ self._server_task = asyncio.create_task(self._serve(listener), name="schedule-mcp")
404
+ async with asyncio.timeout(10):
405
+ while not self._http.started:
406
+ if self._server_task.done():
407
+ await self._server_task
408
+ raise RuntimeError("Schedule MCP listener did not start")
409
+ await asyncio.sleep(0.01)
410
+ except BaseException:
411
+ listener.close()
412
+ await self.close()
413
+ raise
414
+
415
+ async def _serve(self, listener: socket.socket) -> None:
416
+ assert self._http is not None
417
+ try:
418
+ await self._http.serve(sockets=[listener])
419
+ except SystemExit as exc:
420
+ raise RuntimeError("Schedule MCP listener failed to start") from exc
421
+ finally:
422
+ listener.close()
423
+
424
+ async def drain(self, deadline: float) -> bool:
425
+ self._assert_loop()
426
+ if not self._calls:
427
+ return True
428
+ _, pending = await asyncio.wait(self._calls, timeout=max(0, deadline - self._loop.time()))
429
+ return not pending
430
+
431
+ async def close(self) -> None:
432
+ self.close_admission()
433
+ self._closed = True
434
+ if self._http is not None:
435
+ self._http.should_exit = True
436
+ for listener in getattr(self._http, "servers", ()):
437
+ listener.close()
438
+ task = self._server_task
439
+ if task is not None and not task.done():
440
+ try:
441
+ async with asyncio.timeout(7):
442
+ await asyncio.shield(task)
443
+ except (TimeoutError, asyncio.CancelledError) as exc:
444
+ # An outer shutdown budget may expire before Uvicorn's own
445
+ # graceful deadline. Shielding the server must not leave
446
+ # management callbacks accessing a subsequently closed Store.
447
+ pending = {task, *self._calls}
448
+ for active in pending:
449
+ active.cancel()
450
+ await asyncio.wait(pending, timeout=0.1)
451
+ if isinstance(exc, asyncio.CancelledError):
452
+ raise
453
+
454
+ def _assert_loop(self) -> None:
455
+ if self._loop is None or asyncio.get_running_loop() is not self._loop:
456
+ raise RuntimeError("Schedule MCP must run on its owning event loop")
457
+
458
+ async def _list_tools(self, ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
459
+ return ListToolsResult(tools=[Tool(
460
+ name="cron_manage", description=TOOL_DESCRIPTION,
461
+ input_schema=_tool_schema(), annotations=ToolAnnotations(read_only_hint=False),
462
+ )])
463
+
464
+ async def _call_tool(self, ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
465
+ if params.name != "cron_manage":
466
+ return _error("unknown_tool", "Only cron_manage is available.")
467
+ if not self._admission or self._callback is None:
468
+ return _error("unavailable", "Scheduled task management is not ready. Retry later.")
469
+ try:
470
+ request = _Arguments.model_validate(params.arguments or {}).model_dump(exclude_unset=True)
471
+ request = {key: value for key, value in request.items() if value is not None}
472
+ if "schedule" in request:
473
+ request["schedule"] = {key: value for key, value in request["schedule"].items() if value is not None}
474
+ except ValidationError as error:
475
+ return _invalid_arguments(params.arguments, error)
476
+ try:
477
+ native_thread_id = _native_thread_id(ctx.meta)
478
+ except (ValueError, TypeError):
479
+ return _error("invalid_call_context", "Native Thread metadata is malformed or conflicting.")
480
+ task = asyncio.current_task()
481
+ assert task is not None
482
+ self._calls.add(task)
483
+ try:
484
+ async with asyncio.timeout(CALL_TIMEOUT_SECONDS):
485
+ value = await self._callback(request, native_thread_id)
486
+ return _result(value)
487
+ except TimeoutError:
488
+ return _error("timeout", "Management timed out. Retry the same mutation with the same request_id.")
489
+ except Exception:
490
+ # Exception text and tracebacks may contain task instructions or credentials.
491
+ logger.error("Scheduled task management callback failed")
492
+ return _error("internal_error", "Scheduled task management failed. Check the current plan before retrying.")
493
+ finally:
494
+ self._calls.discard(task)
495
+
496
+ def _guard(self, app):
497
+ async def guarded(scope, receive, send):
498
+ if scope["type"] != "http":
499
+ await app(scope, receive, send)
500
+ return
501
+ headers = scope.get("headers", [])
502
+ authorization = [value for key, value in headers if key.lower() == b"authorization"]
503
+ expected = f"Bearer {self._token}".encode("ascii")
504
+ if len(authorization) != 1 or not secrets.compare_digest(authorization[0], expected):
505
+ await self._http_error(send, 401, "unauthorized")
506
+ return
507
+ # No browser client is part of this private process endpoint.
508
+ if any(key.lower() == b"origin" for key, _ in headers):
509
+ await self._http_error(send, 403, "origin_not_allowed")
510
+ return
511
+ response_started = False
512
+
513
+ async def tracked_send(message):
514
+ nonlocal response_started
515
+ if message["type"] == "http.response.start":
516
+ response_started = True
517
+ await send(message)
518
+
519
+ try:
520
+ async with asyncio.timeout(HTTP_TIMEOUT_SECONDS):
521
+ await app(scope, receive, tracked_send)
522
+ except TimeoutError:
523
+ if not response_started:
524
+ await self._http_error(send, 408, "request_timeout")
525
+
526
+ return guarded
527
+
528
+ @staticmethod
529
+ async def _http_error(send, status: int, code: str) -> None:
530
+ body = json.dumps({"error": code}).encode("ascii")
531
+ await send({"type": "http.response.start", "status": status, "headers": [
532
+ (b"content-type", b"application/json"),
533
+ (b"content-length", str(len(body)).encode("ascii")),
534
+ ]})
535
+ await send({"type": "http.response.body", "body": body})