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,126 @@
1
+ """Immutable session configuration intent shared by Bindings and plans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from .domain import MentionContextMode
9
+
10
+ if TYPE_CHECKING:
11
+ from .bindings import ThreadBinding
12
+ from .model_settings import ModelCatalog
13
+
14
+
15
+ class SessionSettingsError(ValueError):
16
+ code = "invalid_session_settings"
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class BindingTurnSettings:
21
+ """Catalog selection to apply to every new Turn started by Netizen."""
22
+
23
+ model_id: str
24
+ effort_id: str
25
+ service_tier_id: str
26
+
27
+ def __post_init__(self) -> None:
28
+ values = (self.model_id, self.effort_id, self.service_tier_id)
29
+ if not all(isinstance(value, str) and value for value in values):
30
+ raise ValueError("Binding Turn settings IDs must not be empty")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class BindingTaskFeedback:
35
+ """Binding-scoped pulse/card/completion feedback choices for Turns."""
36
+
37
+ reaction_pulse_enabled: bool = False
38
+ progress_card_enabled: bool = False
39
+ completion_mention_enabled: bool = True
40
+
41
+ def __post_init__(self) -> None:
42
+ values = (self.reaction_pulse_enabled, self.progress_card_enabled, self.completion_mention_enabled)
43
+ if not all(type(value) is bool for value in values):
44
+ raise ValueError("Binding task feedback values must be booleans")
45
+
46
+
47
+ _FIELDS = frozenset({"turn_settings", "reaction_pulse_enabled", "progress_card_enabled", "completion_mention_enabled", "message_context_mode"})
48
+ _TURN_FIELDS = frozenset({"model_id", "effort_id", "service_tier_id"})
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class SessionSettings:
53
+ turn_settings: BindingTurnSettings | None = None
54
+ task_feedback: BindingTaskFeedback = BindingTaskFeedback()
55
+ message_context_mode: MentionContextMode = MentionContextMode.CURRENT_ONLY
56
+
57
+ def __post_init__(self) -> None:
58
+ if self.turn_settings is not None and not isinstance(self.turn_settings, BindingTurnSettings):
59
+ raise SessionSettingsError("turn_settings 必须是完整模型设置或 null。")
60
+ if not isinstance(self.task_feedback, BindingTaskFeedback):
61
+ raise SessionSettingsError("执行反馈必须是明确的布尔设置。")
62
+ if not isinstance(self.message_context_mode, MentionContextMode):
63
+ raise SessionSettingsError("消息上下文必须是 current-only 或 catch-up。")
64
+
65
+ def to_dict(self) -> dict[str, Any]:
66
+ return {
67
+ "turn_settings": asdict(self.turn_settings) if self.turn_settings else None,
68
+ "reaction_pulse_enabled": self.task_feedback.reaction_pulse_enabled,
69
+ "progress_card_enabled": self.task_feedback.progress_card_enabled,
70
+ "completion_mention_enabled": self.task_feedback.completion_mention_enabled,
71
+ "message_context_mode": self.message_context_mode.value,
72
+ }
73
+
74
+ @classmethod
75
+ def from_dict(cls, value: dict[str, Any]) -> SessionSettings:
76
+ if not isinstance(value, dict) or set(value) != _FIELDS:
77
+ raise SessionSettingsError("会话设置必须包含全部五个配置字段,且不能包含其他字段。")
78
+ raw_turn = value["turn_settings"]
79
+ turn = None
80
+ if raw_turn is not None:
81
+ if not isinstance(raw_turn, dict) or set(raw_turn) != _TURN_FIELDS:
82
+ raise SessionSettingsError("模型设置需要 model_id、effort_id、service_tier_id,或用 null 继承 Codex。")
83
+ try:
84
+ turn = BindingTurnSettings(**raw_turn)
85
+ except ValueError as error:
86
+ raise SessionSettingsError("模型、思考强度和速度 ID 必须是非空字符串。") from error
87
+ try:
88
+ feedback = BindingTaskFeedback(
89
+ value["reaction_pulse_enabled"], value["progress_card_enabled"], value["completion_mention_enabled"],
90
+ )
91
+ except ValueError as error:
92
+ raise SessionSettingsError("执行中表情闪烁、进度卡和结束时 @ 提醒必须是布尔值。") from error
93
+ try:
94
+ mode = MentionContextMode(value["message_context_mode"])
95
+ except (ValueError, TypeError) as error:
96
+ raise SessionSettingsError("消息上下文必须是 current-only 或 catch-up。") from error
97
+ return cls(turn, feedback, mode)
98
+
99
+ def merge(self, partial: dict[str, Any]) -> SessionSettings:
100
+ if not isinstance(partial, dict) or set(partial) - _FIELDS:
101
+ raise SessionSettingsError("会话设置更新必须是仅包含支持字段的对象。")
102
+ return self.from_dict({**self.to_dict(), **partial})
103
+
104
+ @classmethod
105
+ def from_binding(cls, binding: ThreadBinding) -> SessionSettings:
106
+ # All nested objects are immutable. Do not copy the Binding's context
107
+ # anchor: each new session must establish its own message boundary.
108
+ return cls(binding.turn_settings, binding.task_feedback, binding.message_context_mode)
109
+
110
+ @classmethod
111
+ def new_defaults(cls, catalog: ModelCatalog | None) -> SessionSettings:
112
+ feedback = BindingTaskFeedback(progress_card_enabled=True)
113
+ if catalog is None:
114
+ return cls(task_feedback=feedback)
115
+ model = catalog.default_model
116
+ return cls(turn_settings=BindingTurnSettings(
117
+ model.id, model.default_effort_id, model.default_service_tier_id,
118
+ ), task_feedback=feedback)
119
+
120
+ def validate_catalog(self, catalog: ModelCatalog) -> None:
121
+ if self.turn_settings is not None:
122
+ catalog.resolve(
123
+ model_id=self.turn_settings.model_id,
124
+ effort_id=self.turn_settings.effort_id,
125
+ service_tier_id=self.turn_settings.service_tier_id,
126
+ )
@@ -0,0 +1,216 @@
1
+ """Small YAML configuration and protected Lark application profile loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import ipaddress
7
+ import re
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from .lark_app import LARK_APP_RELATIVE_PATH, LarkAppConfigError, load_lark_app
16
+ from .admin.port_config import ConfigFileSnapshot
17
+
18
+
19
+ class SettingsError(ValueError):
20
+ pass
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class AdminWebSettings:
25
+ enabled: bool = True
26
+ host: str = "0.0.0.0"
27
+ port: int | None = None
28
+ access_host: str | None = None
29
+ credential_path: Path | None = field(default=None, repr=False)
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Settings:
34
+ app_id: str
35
+ app_secret: str = field(repr=False)
36
+ project_root: Path
37
+ data_dir: Path = Path(".netizen-data")
38
+ projects: dict[str, Path] = field(default_factory=dict)
39
+ security_mode: str = "audit"
40
+ admin_web: AdminWebSettings = AdminWebSettings()
41
+ config_path: Path | None = None
42
+ config_snapshot: ConfigFileSnapshot | None = field(default=None, repr=False)
43
+
44
+ @classmethod
45
+ def from_file(
46
+ cls,
47
+ path: str | Path,
48
+ environment: Mapping[str, str] | None = None,
49
+ ) -> "Settings":
50
+ env = os.environ if environment is None else environment
51
+ config_path = Path(path).expanduser().resolve(strict=True)
52
+ config_snapshot = ConfigFileSnapshot.read(config_path)
53
+ try:
54
+ loaded = yaml.safe_load(config_snapshot.content)
55
+ except yaml.YAMLError as error:
56
+ raise SettingsError(f"invalid YAML: {error}") from error
57
+ if not isinstance(loaded, dict):
58
+ raise SettingsError("configuration root must be a mapping")
59
+ if "access" in loaded:
60
+ raise SettingsError(
61
+ "access allowlists are not supported; configure availability "
62
+ "in the Feishu app console"
63
+ )
64
+
65
+ instance = _mapping(loaded, "instance")
66
+ channel = _optional_mapping(loaded, "channel")
67
+ admin_web = _admin_web_settings(loaded, env)
68
+ raw_projects = loaded.get("projects", {})
69
+ if not isinstance(raw_projects, dict):
70
+ raise SettingsError("projects must map aliases to absolute paths")
71
+
72
+ if env.get("FEISHU_APP_SECRET") or env.get("FEISHU_APP_SECRET_FILE"):
73
+ raise SettingsError(
74
+ "Feishu secret environment sources are no longer supported; use NETIZEN_LARK_APP_CONFIG"
75
+ )
76
+ credential_path = env.get("NETIZEN_LARK_APP_CONFIG", "").strip()
77
+ try:
78
+ credentials = load_lark_app(
79
+ Path(credential_path) if credential_path
80
+ else config_path.parent / LARK_APP_RELATIVE_PATH
81
+ )
82
+ except LarkAppConfigError as error:
83
+ raise SettingsError(str(error)) from None
84
+ data_dir = _absolute_path(instance, "dataDir")
85
+ project_root = _absolute_path(instance, "projectRoot")
86
+ projects: dict[str, Path] = {}
87
+ for alias, value in raw_projects.items():
88
+ if not isinstance(alias, str) or not isinstance(value, str):
89
+ raise SettingsError("projects must map string aliases to string paths")
90
+ project_path = Path(value).expanduser()
91
+ if not project_path.is_absolute():
92
+ raise SettingsError(f"Project {alias} path must be absolute")
93
+ projects[alias] = project_path
94
+
95
+ security_mode = channel.get("securityMode", "audit")
96
+ if not isinstance(security_mode, str) or security_mode not in {
97
+ "compat",
98
+ "audit",
99
+ "strict",
100
+ }:
101
+ raise SettingsError("channel.securityMode must be compat, audit, or strict")
102
+
103
+ return cls(
104
+ app_id=credentials.app_id,
105
+ app_secret=credentials.app_secret,
106
+ data_dir=data_dir,
107
+ project_root=project_root,
108
+ projects=projects,
109
+ security_mode=security_mode,
110
+ admin_web=admin_web,
111
+ config_path=config_path,
112
+ config_snapshot=config_snapshot,
113
+ )
114
+
115
+
116
+ def _mapping(parent: Mapping[str, Any], name: str) -> Mapping[str, Any]:
117
+ value = parent.get(name)
118
+ if not isinstance(value, dict):
119
+ raise SettingsError(f"{name} must be a mapping")
120
+ return value
121
+
122
+
123
+ def _optional_mapping(parent: Mapping[str, Any], name: str) -> Mapping[str, Any]:
124
+ if name not in parent:
125
+ return {}
126
+ return _mapping(parent, name)
127
+
128
+
129
+ def _string(parent: Mapping[str, Any], name: str) -> str:
130
+ value = parent.get(name)
131
+ if not isinstance(value, str) or not value.strip():
132
+ raise SettingsError(f"{name} must be a non-empty string")
133
+ return value.strip()
134
+
135
+
136
+ def _absolute_path(parent: Mapping[str, Any], name: str) -> Path:
137
+ value = Path(_string(parent, name)).expanduser()
138
+ if not value.is_absolute():
139
+ raise SettingsError(f"{name} must be an absolute path")
140
+ return value
141
+
142
+
143
+ def _admin_web_settings(
144
+ loaded: Mapping[str, Any],
145
+ environment: Mapping[str, str],
146
+ ) -> AdminWebSettings:
147
+ raw_secret = environment.get("NETIZEN_ADMIN_SECRET", "")
148
+ if raw_secret:
149
+ raise SettingsError(
150
+ "NETIZEN_ADMIN_SECRET is not supported; use "
151
+ "NETIZEN_ADMIN_SECRET_FILE"
152
+ )
153
+ values = _optional_mapping(loaded, "adminWeb")
154
+ unknown = values.keys() - {"enabled", "host", "port", "accessHost"}
155
+ if unknown:
156
+ raise SettingsError("adminWeb contains unsupported settings")
157
+ enabled = values.get("enabled", True)
158
+ if not isinstance(enabled, bool):
159
+ raise SettingsError("adminWeb.enabled must be a boolean")
160
+ host = values.get("host", "0.0.0.0")
161
+ if (
162
+ not isinstance(host, str)
163
+ or not host
164
+ or host.strip() != host
165
+ or any(ord(character) < 0x20 for character in host)
166
+ ):
167
+ raise SettingsError("adminWeb.host must be a non-empty host")
168
+ port = values.get("port")
169
+ if "port" in values and (
170
+ isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535
171
+ ):
172
+ raise SettingsError("adminWeb.port must be an integer from 1 to 65535")
173
+ access_host = validate_access_host(values["accessHost"]) if "accessHost" in values else None
174
+ raw_path = environment.get("NETIZEN_ADMIN_SECRET_FILE", "").strip()
175
+ credential_path: Path | None = None
176
+ if raw_path:
177
+ credential_path = Path(raw_path)
178
+ if not credential_path.is_absolute():
179
+ raise SettingsError("NETIZEN_ADMIN_SECRET_FILE must be an absolute path")
180
+ elif enabled:
181
+ raise SettingsError(
182
+ "NETIZEN_ADMIN_SECRET_FILE is required when Admin Web is enabled"
183
+ )
184
+ return AdminWebSettings(
185
+ enabled=enabled,
186
+ host=host,
187
+ port=port,
188
+ access_host=access_host,
189
+ credential_path=credential_path,
190
+ )
191
+
192
+
193
+ def validate_access_host(value: object) -> str:
194
+ """A hostname or bare IP only; never a URL, port, or wildcard listener."""
195
+
196
+ message = "adminWeb.accessHost must be a hostname or IP without scheme, port, path, or wildcard"
197
+ if not isinstance(value, str) or not value or value.strip() != value:
198
+ raise SettingsError(message)
199
+ if any(character in value for character in "/\\@?#[]%"):
200
+ raise SettingsError(message)
201
+ try:
202
+ address = ipaddress.ip_address(value)
203
+ except ValueError:
204
+ try:
205
+ hostname = value.removesuffix(".").encode("idna").decode("ascii").lower()
206
+ except UnicodeError:
207
+ raise SettingsError(message) from None
208
+ if len(hostname) > 253 or any(
209
+ not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
210
+ for label in hostname.split(".")
211
+ ):
212
+ raise SettingsError(message)
213
+ return hostname
214
+ if address.is_unspecified:
215
+ raise SettingsError(message)
216
+ return address.compressed
@@ -0,0 +1,40 @@
1
+ """Parse the explicit, current-message Skill reference prefix."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ _SKILL_REFERENCE = re.compile(r"\$([A-Za-z0-9][A-Za-z0-9_.:-]{0,127})(?=\s|$)")
9
+
10
+
11
+ class InvalidSkillReference(ValueError):
12
+ pass
13
+
14
+
15
+ def parse_skill_references(text: str) -> tuple[str, ...]:
16
+ """Return the leading ``$name`` block, preserving user order.
17
+
18
+ Restricting activation to a leading reference block avoids interpreting
19
+ shell variables, prose, or code later in a prompt as executable Skills.
20
+ The caller must parse only the current user message, never quoted/history
21
+ material.
22
+ """
23
+
24
+ position = 0
25
+ length = len(text)
26
+ while position < length and text[position].isspace():
27
+ position += 1
28
+ names: list[str] = []
29
+ while position < length and text[position] == "$":
30
+ match = _SKILL_REFERENCE.match(text, position)
31
+ if match is None:
32
+ break
33
+ name = match.group(1)
34
+ if name in names:
35
+ raise InvalidSkillReference(f"Skill ${name} 在同一条消息中重复引用。")
36
+ names.append(name)
37
+ position = match.end()
38
+ while position < length and text[position].isspace():
39
+ position += 1
40
+ return tuple(names)
@@ -0,0 +1,155 @@
1
+ """Version-gated access to fixed background-terminal App Server methods.
2
+
3
+ This module is intentionally the only place for the experimental terminal
4
+ inspection/cleanup compatibility contract. It is not a general JSON-RPC adapter: the
5
+ method name, request shape, response shape, SDK version, and relevant SDK
6
+ implementation fingerprints are all fixed here. ADR 0014's independently
7
+ removable Goal/Skills facade-gap adapters do not weaken this gate.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+ import openai_codex
17
+ from openai_codex import AsyncCodex
18
+ from openai_codex.async_client import AsyncCodexClient
19
+ from openai_codex.client import CodexClient
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+
23
+ SUPPORTED_SDK_VERSION = "0.156.1"
24
+ _CLEAN_METHOD = "thread/backgroundTerminals/clean"
25
+ _LIST_METHOD = "thread/backgroundTerminals/list"
26
+ _PACKAGE_SOURCE_FINGERPRINT = (
27
+ "7d0a2267e45d39934c64d2c01bddde8b36eacb2d771dd6903a056c6abef8e94f"
28
+ )
29
+
30
+
31
+ class UnsupportedCleanupSdk(RuntimeError):
32
+ """The installed SDK no longer matches the explicitly approved shim."""
33
+
34
+
35
+ class TerminalCleanup(Protocol):
36
+ """Request cleanup of App Server-registered background terminals only."""
37
+
38
+ async def clean_thread(self, thread_id: str) -> None: ...
39
+
40
+
41
+ class BackgroundTerminalInspector(Protocol):
42
+ """Report only whether App Server has a registered terminal for a Thread."""
43
+
44
+ async def has_running(self, thread_id: str) -> bool: ...
45
+
46
+
47
+ class _CleanupResponse(BaseModel):
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+
51
+ class _BackgroundTerminalListResponse(BaseModel):
52
+ """Minimal envelope; terminal identities never leave this adapter."""
53
+
54
+ model_config = ConfigDict(extra="forbid")
55
+
56
+ data: tuple[dict[str, object], ...]
57
+ next_cursor: str | None = Field(default=None, alias="nextCursor")
58
+
59
+
60
+ class PinnedExperimentalTerminalCleanup:
61
+ """Inspect or clean registered background terminals for one exact Thread.
62
+
63
+ Construction validates the private ownership edge before the service can
64
+ accept a Turn. The underlying low-level client's typed ``request`` method
65
+ is public, but the high-level ``AsyncCodex._client`` reference is not; that
66
+ single reach-through is the narrow, temporary compatibility shim approved
67
+ in ADR 0009. A successful empty response attests only that the request was
68
+ accepted; it does not attest that a foreground tool process exited (ADR
69
+ 0010).
70
+ """
71
+
72
+ __slots__ = ("_client",)
73
+
74
+ def __init__(self, codex: AsyncCodex) -> None:
75
+ _validate_contract(codex)
76
+ self._client = codex._client
77
+
78
+ async def clean_thread(self, thread_id: str) -> None:
79
+ _validate_thread_id(thread_id)
80
+ await self._client.request(
81
+ _CLEAN_METHOD,
82
+ {"threadId": thread_id},
83
+ response_model=_CleanupResponse,
84
+ )
85
+
86
+ async def has_running(self, thread_id: str) -> bool:
87
+ """Return a presence bit without exposing process metadata to Runtime."""
88
+
89
+ _validate_thread_id(thread_id)
90
+ response = await self._client.request(
91
+ _LIST_METHOD,
92
+ {"threadId": thread_id, "limit": 1},
93
+ response_model=_BackgroundTerminalListResponse,
94
+ )
95
+ # A non-empty cursor with no first-page item is an unexpected shape;
96
+ # conservatively report presence so Runtime cannot unsubscribe.
97
+ return bool(response.data or response.next_cursor)
98
+
99
+
100
+ def _validate_thread_id(thread_id: str) -> None:
101
+ if not thread_id or thread_id.strip() != thread_id:
102
+ raise ValueError("native Thread ID must be a non-empty trimmed string")
103
+
104
+
105
+ def _validate_contract(codex: AsyncCodex) -> None:
106
+ if openai_codex.__version__ != SUPPORTED_SDK_VERSION:
107
+ raise UnsupportedCleanupSdk(
108
+ "experimental terminal cleanup supports only openai-codex=="
109
+ f"{SUPPORTED_SDK_VERSION}; found {openai_codex.__version__}"
110
+ )
111
+ if type(codex) is not AsyncCodex:
112
+ raise UnsupportedCleanupSdk("AsyncCodex implementation type changed")
113
+ if getattr(codex, "_initialized", False) is not True:
114
+ raise UnsupportedCleanupSdk(
115
+ "AsyncCodex must be initialized before terminal cleanup is enabled"
116
+ )
117
+
118
+ client = getattr(codex, "_client", None)
119
+ if type(client) is not AsyncCodexClient:
120
+ raise UnsupportedCleanupSdk("AsyncCodex private client shape changed")
121
+ sync_client = getattr(client, "_sync", None)
122
+ if type(sync_client) is not CodexClient:
123
+ raise UnsupportedCleanupSdk("AsyncCodexClient private sync client shape changed")
124
+ if getattr(getattr(sync_client, "config", None), "experimental_api", None) is not True:
125
+ raise UnsupportedCleanupSdk(
126
+ "Codex App Server experimentalApi capability is not enabled"
127
+ )
128
+
129
+ package_file = getattr(openai_codex, "__file__", None)
130
+ if not isinstance(package_file, str) or not package_file.endswith(".py"):
131
+ raise UnsupportedCleanupSdk("cannot locate SDK source package")
132
+ try:
133
+ actual = _source_tree_fingerprint(Path(package_file).resolve().parent)
134
+ except OSError as error:
135
+ raise UnsupportedCleanupSdk("cannot read SDK source package") from error
136
+ if actual != _PACKAGE_SOURCE_FINGERPRINT:
137
+ raise UnsupportedCleanupSdk("SDK package source fingerprint changed")
138
+
139
+
140
+ def _source_tree_fingerprint(package_root: Path) -> str:
141
+ source_files = sorted(
142
+ (path for path in package_root.rglob("*.py") if path.is_file()),
143
+ key=lambda path: path.relative_to(package_root).as_posix(),
144
+ )
145
+ if not source_files:
146
+ raise OSError("SDK source package contains no Python files")
147
+ digest = hashlib.sha256()
148
+ for source_path in source_files:
149
+ relative = source_path.relative_to(package_root).as_posix().encode("utf-8")
150
+ source = source_path.read_bytes()
151
+ digest.update(len(relative).to_bytes(4, "big"))
152
+ digest.update(relative)
153
+ digest.update(len(source).to_bytes(8, "big"))
154
+ digest.update(source)
155
+ return digest.hexdigest()