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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- 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})
|