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,281 @@
1
+ """Bounded Admin maintenance state and its per-instance lock.
2
+
3
+ Schema 3 describes a package/environment-bound restart. Historical schemas
4
+ remain readable for diagnosis; they do not grant package-update authority.
5
+ This module deliberately uses only the standard library.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fcntl
11
+ from contextlib import contextmanager
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import secrets
17
+ import stat
18
+ import tempfile
19
+ import time
20
+ from typing import Any
21
+ from collections.abc import Iterator
22
+
23
+
24
+ OPERATION_FILE = "update.json"
25
+ MAX_OPERATION_BYTES = 4096
26
+ OPERATION_ID = re.compile(r"[0-9a-f]{32}")
27
+ SHA256 = re.compile(r"[0-9a-f]{64}")
28
+ VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)")
29
+ PHASES = frozenset({
30
+ "accepted", "downloading", "preparing", "installing", "restarting",
31
+ "succeeded", "failed", "rolled_back", "requires_action", "recovery_required", "recovered",
32
+ })
33
+ TERMINAL_PHASES = frozenset({
34
+ "succeeded", "failed", "rolled_back", "requires_action", "recovery_required", "recovered",
35
+ })
36
+ CODES = frozenset({
37
+ "none", "download_failed", "installer_invalid", "preparation_failed",
38
+ "configuration_required", "permissions_required", "activation_failed",
39
+ "rollback_incomplete", "installer_failed", "worker_interrupted", "lock_busy",
40
+ "operation_invalid", "dispatch_failed", "dispatch_unknown", "worker_lost",
41
+ "previous_release_changed", "profile_failed", "manual_recovery",
42
+ "restart_failed", "service_ready",
43
+ })
44
+
45
+
46
+ class UpdateProtocolError(RuntimeError):
47
+ """The deployment state cannot safely be read or changed."""
48
+
49
+
50
+ def _matches(pattern: re.Pattern[str], value: object) -> bool:
51
+ return isinstance(value, str) and pattern.fullmatch(value) is not None
52
+
53
+
54
+ def validate_target(value: object) -> dict[str, Any]:
55
+ if (
56
+ not isinstance(value, dict)
57
+ or set(value) != {"version", "releaseId", "installerSha256", "archiveSha256"}
58
+ or not _matches(VERSION, value.get("version"))
59
+ or type(value.get("releaseId")) is not int
60
+ or not 0 < value["releaseId"] < 2**63
61
+ or not _matches(SHA256, value.get("installerSha256"))
62
+ or not _matches(SHA256, value.get("archiveSha256"))
63
+ ):
64
+ raise UpdateProtocolError("invalid update target")
65
+ return dict(value)
66
+
67
+
68
+ def validate_operation(value: object) -> dict[str, Any]:
69
+ if isinstance(value, dict) and value.get("schema") == 3:
70
+ return _validate_cli_restart(value)
71
+ restart = isinstance(value, dict) and value.get("schema") == 2
72
+ keys = {
73
+ "schema", "operationId", "target", "previousRelease", "phase", "code",
74
+ "createdAt", "updatedAt",
75
+ }
76
+ if restart:
77
+ keys.add("kind")
78
+ if (
79
+ not isinstance(value, dict)
80
+ or set(value) != keys
81
+ or type(value.get("schema")) is not int or value["schema"] not in {1, 2}
82
+ or not _matches(OPERATION_ID, value.get("operationId"))
83
+ or not _matches(SHA256, value.get("previousRelease"))
84
+ or not isinstance(value.get("phase"), str) or value["phase"] not in PHASES
85
+ or not isinstance(value.get("code"), str) or value["code"] not in CODES
86
+ or type(value.get("createdAt")) is not int
87
+ or type(value.get("updatedAt")) is not int
88
+ or not 0 <= value["createdAt"] <= value["updatedAt"] < 2**63
89
+ ):
90
+ raise UpdateProtocolError("invalid update operation")
91
+ if value["code"] == "service_ready" and (not restart or value["phase"] != "recovered"):
92
+ raise UpdateProtocolError("service readiness only recovers a restart")
93
+ if restart:
94
+ target = value["target"]
95
+ if (value["kind"] != "restart" or not isinstance(target, dict)
96
+ or set(target) != {"version", "releaseDigest"}
97
+ or not _matches(VERSION, target.get("version"))
98
+ or target.get("releaseDigest") != value["previousRelease"]
99
+ or value["phase"] not in {
100
+ "accepted", "restarting", "succeeded", "failed", "recovery_required", "recovered",
101
+ }):
102
+ raise UpdateProtocolError("invalid restart operation")
103
+ return {**value, "target": dict(target)}
104
+ return {**value, "target": validate_target(value["target"])}
105
+
106
+
107
+ def _validate_cli_restart(value: dict[str, Any]) -> dict[str, Any]:
108
+ target = value.get("target")
109
+ if (set(value) != {"schema", "kind", "operationId", "target", "phase", "code",
110
+ "createdAt", "updatedAt"}
111
+ or type(value["schema"]) is not int or value["schema"] != 3
112
+ or value.get("kind") != "restart"
113
+ or not _matches(OPERATION_ID, value.get("operationId"))
114
+ or not isinstance(target, dict) or set(target) != {"version", "installationId"}
115
+ or not isinstance(target.get("version"), str)
116
+ or not 0 < len(target["version"]) <= 128
117
+ or any(ord(character) < 32 for character in target["version"])
118
+ or not _matches(SHA256, target.get("installationId"))
119
+ or not isinstance(value.get("phase"), str) or value["phase"] not in {
120
+ "accepted", "restarting", "succeeded", "failed", "recovery_required", "recovered",
121
+ }
122
+ or not isinstance(value.get("code"), str) or value["code"] not in CODES
123
+ or type(value.get("createdAt")) is not int
124
+ or type(value.get("updatedAt")) is not int
125
+ or not 0 <= value["createdAt"] <= value["updatedAt"] < 2**63
126
+ or (value["code"] == "service_ready" and value["phase"] != "recovered")):
127
+ raise UpdateProtocolError("invalid CLI restart operation")
128
+ return {**value, "target": dict(target)}
129
+
130
+
131
+ def new_cli_restart_operation(version: str, installation_id: str) -> dict[str, Any]:
132
+ now = int(time.time())
133
+ return validate_operation({
134
+ "schema": 3, "kind": "restart", "operationId": secrets.token_hex(16),
135
+ "target": {"version": version, "installationId": installation_id},
136
+ "phase": "accepted", "code": "none", "createdAt": now, "updatedAt": now,
137
+ })
138
+
139
+
140
+ def terminal_phase(phase: str) -> bool:
141
+ return phase in TERMINAL_PHASES
142
+
143
+
144
+ def _state_directory(product_root: Path) -> Path:
145
+ for directory in (product_root, product_root / "state"):
146
+ info = directory.lstat()
147
+ if (
148
+ not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
149
+ or stat.S_IMODE(info.st_mode) & 0o022
150
+ ):
151
+ raise UpdateProtocolError("unsafe update state directory")
152
+ return product_root / "state"
153
+
154
+
155
+ def _check_file(info: os.stat_result, *, private: bool = True) -> None:
156
+ if (
157
+ not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
158
+ or info.st_nlink != 1
159
+ or (private and stat.S_IMODE(info.st_mode) != 0o600)
160
+ ):
161
+ raise UpdateProtocolError("unsafe update state file")
162
+
163
+
164
+ def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
165
+ value: dict[str, Any] = {}
166
+ for key, item in pairs:
167
+ if key in value:
168
+ raise ValueError("duplicate update state field")
169
+ value[key] = item
170
+ return value
171
+
172
+
173
+ def read_operation(product_root: Path) -> dict[str, Any] | None:
174
+ try:
175
+ directory = _state_directory(product_root)
176
+ descriptor = os.open(directory / OPERATION_FILE, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
177
+ except FileNotFoundError:
178
+ return None
179
+ except OSError as error:
180
+ raise UpdateProtocolError("could not read update operation") from error
181
+ try:
182
+ _check_file(os.fstat(descriptor))
183
+ with os.fdopen(descriptor, "rb", closefd=False) as source:
184
+ payload = source.read(MAX_OPERATION_BYTES + 1)
185
+ if len(payload) > MAX_OPERATION_BYTES:
186
+ raise UpdateProtocolError("update operation exceeds size limit")
187
+ return validate_operation(json.loads(payload, object_pairs_hook=_unique_json_object))
188
+ except (ValueError, UnicodeError, OSError, RecursionError) as error:
189
+ raise UpdateProtocolError("could not read update operation") from error
190
+ finally:
191
+ os.close(descriptor)
192
+
193
+
194
+ def write_operation(product_root: Path, operation: object) -> None:
195
+ value = validate_operation(operation)
196
+ payload = (json.dumps(value, separators=(",", ":"), sort_keys=True) + "\n").encode()
197
+ if len(payload) > MAX_OPERATION_BYTES:
198
+ raise UpdateProtocolError("update operation exceeds size limit")
199
+ temporary: str | None = None
200
+ try:
201
+ directory = _state_directory(product_root)
202
+ destination = directory / OPERATION_FILE
203
+ try:
204
+ _check_file(destination.lstat())
205
+ except FileNotFoundError:
206
+ pass
207
+ descriptor, temporary = tempfile.mkstemp(prefix=".update-", dir=directory)
208
+ with os.fdopen(descriptor, "wb") as output:
209
+ output.write(payload)
210
+ output.flush()
211
+ os.fsync(output.fileno())
212
+ os.replace(temporary, destination)
213
+ temporary = None
214
+ descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
215
+ try:
216
+ os.fsync(descriptor)
217
+ finally:
218
+ os.close(descriptor)
219
+ except OSError as error:
220
+ raise UpdateProtocolError("could not write update operation") from error
221
+ finally:
222
+ if temporary is not None:
223
+ os.unlink(temporary)
224
+
225
+
226
+ def advance_operation(product_root: Path, operation_id: str, phase: str, code: str = "none") -> dict[str, Any]:
227
+ """Write a phase while the caller owns the shared installation lock."""
228
+ operation = read_operation(product_root)
229
+ if operation is None or operation["operationId"] != operation_id:
230
+ raise UpdateProtocolError("update operation changed")
231
+ operation.update(phase=phase, code=code, updatedAt=max(int(time.time()), operation["updatedAt"]))
232
+ write_operation(product_root, operation)
233
+ return operation
234
+
235
+
236
+ def acquire_install_lock(product_root: Path, *, blocking: bool = False) -> int:
237
+ """Return the shared lock FD; closing it releases this acquisition."""
238
+ directory = _state_directory(product_root)
239
+ descriptor = os.open(directory / ".install.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
240
+ try:
241
+ _check_file(os.fstat(descriptor), private=False)
242
+ os.fchmod(descriptor, 0o600)
243
+ fcntl.flock(descriptor, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
244
+ return descriptor
245
+ except BaseException:
246
+ os.close(descriptor)
247
+ raise
248
+
249
+
250
+ def validate_inherited_lock(product_root: Path, descriptor: int) -> None:
251
+ """Accept only the worker's exact locked open-file description."""
252
+ os.set_inheritable(descriptor, False)
253
+ directory = _state_directory(product_root)
254
+ inherited = os.fstat(descriptor)
255
+ _check_file(inherited)
256
+ expected = (directory / ".install.lock").lstat()
257
+ _check_file(expected)
258
+ if (inherited.st_dev, inherited.st_ino) != (expected.st_dev, expected.st_ino):
259
+ raise UpdateProtocolError("update lock descriptor does not match")
260
+ # A separately opened FD must see a held lock; the inherited open-file
261
+ # description must already own it, rather than some unrelated process.
262
+ probe = os.open(directory / ".install.lock", os.O_RDWR | os.O_NOFOLLOW)
263
+ try:
264
+ try:
265
+ fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
266
+ except BlockingIOError:
267
+ pass
268
+ else:
269
+ raise UpdateProtocolError("inherited update lock is not held")
270
+ finally:
271
+ os.close(probe)
272
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
273
+
274
+
275
+ @contextmanager
276
+ def install_lock(product_root: Path, *, blocking: bool = False) -> Iterator[int]:
277
+ descriptor = acquire_install_lock(product_root, blocking=blocking)
278
+ try:
279
+ yield descriptor
280
+ finally:
281
+ os.close(descriptor)
netizen_cli/domain.py ADDED
@@ -0,0 +1,416 @@
1
+ """Channel-facing domain types; native agent state stays in Codex."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from urllib.parse import quote
8
+
9
+
10
+ class ScopeKind(str, Enum):
11
+ DIRECT = "direct"
12
+ GROUP = "group"
13
+ TOPIC = "topic"
14
+
15
+
16
+ class MentionContextMode(str, Enum):
17
+ """Binding-scoped policy for context gathered by an explicit mention."""
18
+
19
+ CURRENT_ONLY = "current-only"
20
+ CATCH_UP = "catch-up"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class MessageContextAnchor:
25
+ """Exact Feishu message boundary for one catch-up Binding."""
26
+
27
+ message_id: str
28
+ create_time_ms: int
29
+
30
+ def __post_init__(self) -> None:
31
+ if not isinstance(self.message_id, str) or not self.message_id:
32
+ raise ValueError("context anchor message_id must not be empty")
33
+ if (
34
+ isinstance(self.create_time_ms, bool)
35
+ or not isinstance(self.create_time_ms, int)
36
+ or self.create_time_ms <= 0
37
+ ):
38
+ raise ValueError("context anchor create_time_ms must be positive")
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class FeishuScope:
43
+ app_id: str
44
+ chat_id: str
45
+ kind: ScopeKind
46
+ topic_id: str | None = None
47
+
48
+ def __post_init__(self) -> None:
49
+ if not self.app_id or not self.chat_id:
50
+ raise ValueError("app_id and chat_id are required")
51
+ if self.kind is ScopeKind.TOPIC and not self.topic_id:
52
+ raise ValueError("a topic scope requires topic_id")
53
+ if self.kind is not ScopeKind.TOPIC and self.topic_id is not None:
54
+ raise ValueError("only a topic scope may carry topic_id")
55
+
56
+ @property
57
+ def key(self) -> str:
58
+ parts = (self.app_id, self.kind.value, self.chat_id, self.topic_id or "")
59
+ return "scope:v1:" + ":".join(quote(part, safe="") for part in parts)
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class PromptInput:
64
+ scope: FeishuScope
65
+ source_id: str
66
+ sender_id: str
67
+ text: str
68
+ skill_names: tuple[str, ...] = ()
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class ScheduledConversation:
73
+ thread_id: str
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class ScheduledOrigin:
78
+ """Automatic attribution and a real bot-message completion destination."""
79
+
80
+ app_id: str
81
+ chat_id: str
82
+ message_id: str
83
+ conversation: ScheduledConversation
84
+ plan_id: str
85
+ run_id: str
86
+
87
+ @property
88
+ def id(self) -> str:
89
+ return self.message_id
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class ScheduledBindingConversation:
94
+ chat_id: str
95
+ chat_type: str
96
+ thread_id: str | None = None
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class ScheduledBindingOrigin:
101
+ """A real trigger anchor, distinct from a Feishu-authored input."""
102
+
103
+ app_id: str
104
+ chat_id: str
105
+ message_id: str
106
+ conversation: ScheduledBindingConversation
107
+ plan_id: str
108
+ run_id: str
109
+ binding_id: str
110
+
111
+ @property
112
+ def id(self) -> str:
113
+ return self.message_id
114
+
115
+
116
+ class NativeCapability(str, Enum):
117
+ SKILLS = "skills"
118
+ GOAL = "goal"
119
+ SIDE = "side"
120
+ RELEASE = "release"
121
+ DELETE = "delete"
122
+
123
+
124
+ class ActiveState(str, Enum):
125
+ RUNNING = "running"
126
+ STOPPING = "stopping"
127
+ OBSERVATION_UNAVAILABLE = "turn-observation-unavailable"
128
+
129
+
130
+ ACTIVE_STATE_VALUES: frozenset[str] = frozenset(state.value for state in ActiveState)
131
+
132
+
133
+ class GoalOperationState(str, Enum):
134
+ STARTING = "goal-starting"
135
+ RUNNING = "goal-running"
136
+ PAUSING = "goal-pausing"
137
+ EXTERNAL_ACTIVE = "externally-active-goal"
138
+ UNKNOWN = "goal-unknown"
139
+
140
+
141
+ class GoalStatus(str, Enum):
142
+ ACTIVE = "active"
143
+ PAUSED = "paused"
144
+ BLOCKED = "blocked"
145
+ USAGE_LIMITED = "usageLimited"
146
+ BUDGET_LIMITED = "budgetLimited"
147
+ COMPLETE = "complete"
148
+
149
+ @property
150
+ def terminal_or_paused(self) -> bool:
151
+ return self is not GoalStatus.ACTIVE
152
+
153
+
154
+ SESSION_IDLE_STATE = "idle"
155
+
156
+
157
+ SESSION_STOP_ACTION_STATES: frozenset[str] = frozenset(
158
+ {
159
+ ActiveState.RUNNING.value,
160
+ ActiveState.STOPPING.value,
161
+ ActiveState.OBSERVATION_UNAVAILABLE.value,
162
+ GoalOperationState.RUNNING.value,
163
+ GoalOperationState.PAUSING.value,
164
+ }
165
+ )
166
+
167
+
168
+ def session_stop_available(primary_status: str | None) -> bool:
169
+ """Return whether a Session status exposes the shared exact Stop control."""
170
+
171
+ return primary_status in SESSION_STOP_ACTION_STATES
172
+
173
+
174
+ class ControlName(str, Enum):
175
+ MENU = "menu"
176
+ NEW = "new"
177
+ SIDE = "side"
178
+ CONFIG = "config"
179
+ DEFAULTS = "defaults"
180
+ COMPACT = "compact"
181
+ SETTINGS = "settings"
182
+ CRON = "cron"
183
+ SESSIONS = "sessions"
184
+ RESUME = "resume"
185
+ RENAME = "rename"
186
+ ARCHIVE = "archive"
187
+ DELETE = "delete"
188
+ UNARCHIVE = "unarchive"
189
+ STOP = "stop"
190
+ RELEASE = "release"
191
+ STATUS = "status"
192
+ ADMIN = "admin"
193
+ GOAL = "goal"
194
+ HELP = "help"
195
+
196
+
197
+ class SettingsSection(str, Enum):
198
+ PROJECTS = "projects"
199
+
200
+
201
+ @dataclass(frozen=True, slots=True)
202
+ class ControlIntent:
203
+ scope: FeishuScope
204
+ source_id: str
205
+ sender_id: str
206
+ name: ControlName
207
+ arguments: tuple[str, ...] = ()
208
+
209
+
210
+ class CardControlName(str, Enum):
211
+ OPEN_SETTINGS_SECTION = "settings.section.open"
212
+ REFRESH_SETTINGS = "settings.refresh"
213
+ REGISTER_PROJECT = "project.register"
214
+ SET_PROJECT_ENABLED = "project.enabled.set"
215
+ CREATE_BINDING = "binding.create"
216
+ CONFIGURE_BINDING = "binding.configure"
217
+ RENAME_BINDING = "binding.rename"
218
+ ARCHIVE_BINDING = "binding.archive"
219
+ DELETE_BINDING = "binding.delete"
220
+ PREPARE_EXACT_DELETE_BINDING = "binding.delete.exact.prepare"
221
+ DELETE_EXACT_BINDING = "binding.delete.exact"
222
+ PREPARE_ARCHIVED_DELETE_BINDING = "binding.delete.archived.prepare"
223
+ DELETE_ARCHIVED_BINDING = "binding.delete.archived"
224
+ UNARCHIVE_BINDING = "binding.unarchive"
225
+ ACTIVATE_BINDING = "binding.activate"
226
+ ARCHIVE_EXACT_BINDING = "binding.archive.exact"
227
+ STOP_EXACT_BINDING = "binding.stop.exact"
228
+ RECHECK_EXACT_TURN = "binding.turn.recheck"
229
+ SESSIONS_PAGE = "sessions.page"
230
+ REFRESH_ARCHIVED_SESSIONS = "sessions.archived.refresh"
231
+ GOAL_PAUSE = "goal.pause"
232
+ GOAL_RESUME = "goal.resume"
233
+ GOAL_CLEAR = "goal.clear"
234
+ SIDE_CLOSE = "side.close"
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class CardControlIntent:
239
+ scope: FeishuScope
240
+ source_id: str
241
+ sender_id: str
242
+ name: CardControlName
243
+ settings_section: SettingsSection | None = None
244
+ project_alias: str | None = None
245
+ expected_revision: int | None = None
246
+ expected_settings_revision: int | None = None
247
+ expected_context_revision: int | None = None
248
+ feedback_revision: int | None = None
249
+ enabled: bool | None = None
250
+ project_path: str | None = None
251
+ create_directory: bool | None = None
252
+ binding_id: str | None = None
253
+ expected_active_binding_id: str | None = None
254
+ expected_native_thread_id: str | None = None
255
+ expected_activity_revision: int | None = None
256
+ expected_turn_id: str | None = None
257
+ thread_name: str | None = None
258
+ model_id: str | None = None
259
+ effort_id: str | None = None
260
+ service_tier_id: str | None = None
261
+ reaction_pulse_enabled: bool | None = None
262
+ progress_card_enabled: bool | None = None
263
+ completion_mention_enabled: bool | None = None
264
+ message_context_mode: MentionContextMode | None = None
265
+ side_id: str | None = None
266
+ page: int | None = None
267
+ goal_generation: str | None = None
268
+ expected_goal_status: str | None = None
269
+
270
+
271
+ class TurnFileActionName(str, Enum):
272
+ PAGE = "turn-file.page"
273
+ SEND = "turn-file.send"
274
+
275
+
276
+ @dataclass(frozen=True, slots=True)
277
+ class TurnFileManifestItem:
278
+ path: str
279
+ label: str
280
+ additions: int | None = None
281
+ deletions: int | None = None
282
+ deleted: bool = False
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class TurnProgressManifestStep:
287
+ step: str
288
+ status: str
289
+
290
+
291
+ @dataclass(frozen=True, slots=True)
292
+ class TurnActivityManifestEntry:
293
+ kind: str
294
+ status: str
295
+ event_timestamp_ms: int | None = None
296
+ text: str | None = None
297
+ count: int = 1
298
+
299
+
300
+ @dataclass(frozen=True, slots=True)
301
+ class TurnCommentaryManifestEntry:
302
+ text: str
303
+ event_timestamp_ms: int | None = None
304
+
305
+
306
+ @dataclass(frozen=True, slots=True)
307
+ class TurnProgressManifest:
308
+ """Sanitized, self-contained progress panel carried by file pagination."""
309
+
310
+ state: str
311
+ steer_count: int
312
+ plan_available: bool
313
+ plan_generated: bool
314
+ plan_may_be_stale: bool
315
+ steps: tuple[TurnProgressManifestStep, ...] = ()
316
+ commentary: tuple[TurnCommentaryManifestEntry, ...] = ()
317
+ operations: tuple[TurnActivityManifestEntry, ...] = ()
318
+
319
+
320
+ @dataclass(frozen=True, slots=True)
321
+ class ReplyCardGoalModule:
322
+ """Frozen, display-only Goal control projection for one Goal generation."""
323
+
324
+ binding_id: str
325
+ short_id: str
326
+ project_alias: str
327
+ goal_generation: str | None
328
+ status: str | None
329
+ runtime_state: str | None
330
+ objective: str | None
331
+ token_budget: int | None
332
+ tokens_used: int
333
+ notice: str | None = None
334
+ notice_is_error: bool = False
335
+
336
+
337
+ @dataclass(frozen=True, slots=True)
338
+ class ReplyCardActivityModule:
339
+ """Bounded, sanitized Turn activity plus its presentation state."""
340
+
341
+ progress: TurnProgressManifest
342
+ terminal_status: str | None = None
343
+ collapsed: bool = False
344
+ hidden_steps: int = 0
345
+
346
+
347
+ @dataclass(frozen=True, slots=True)
348
+ class ReplyCardResultModule:
349
+ content: str
350
+ # One terminal delivery only; never retained in file-page callbacks.
351
+ completion_mention_user_id: str | None = None
352
+
353
+
354
+ @dataclass(frozen=True, slots=True)
355
+ class ReplyCardFileItem:
356
+ """One current file view plus optional exact-Turn line statistics."""
357
+
358
+ path: str
359
+ label: str
360
+ size: int | None
361
+ media_kind: str | None
362
+ additions: int | None = None
363
+ deletions: int | None = None
364
+ deleted: bool = False
365
+
366
+
367
+ @dataclass(frozen=True, slots=True)
368
+ class ReplyCardFilesModule:
369
+ binding_id: str
370
+ turn_id: str
371
+ items: tuple[ReplyCardFileItem, ...]
372
+ page: int = 0
373
+ action_version: int = 5
374
+ additions: int | None = None
375
+ deletions: int | None = None
376
+
377
+
378
+ @dataclass(frozen=True, slots=True)
379
+ class ReplyCardManifest:
380
+ """Frozen non-file modules carried by a self-contained page callback."""
381
+
382
+ goal: ReplyCardGoalModule | None = None
383
+ activity: ReplyCardActivityModule | None = None
384
+ result: ReplyCardResultModule | None = None
385
+
386
+
387
+ @dataclass(frozen=True, slots=True)
388
+ class ReplyCardProjection:
389
+ """Closed Reply Card module set rendered atomically as one Card 2.0 body."""
390
+
391
+ scope: FeishuScope | None = None
392
+ goal: ReplyCardGoalModule | None = None
393
+ activity: ReplyCardActivityModule | None = None
394
+ result: ReplyCardResultModule | None = None
395
+ files: ReplyCardFilesModule | None = None
396
+
397
+
398
+ @dataclass(frozen=True, slots=True)
399
+ class TurnFileActionIntent:
400
+ scope: FeishuScope
401
+ source_id: str
402
+ sender_id: str
403
+ name: TurnFileActionName
404
+ binding_id: str
405
+ turn_id: str
406
+ page: int | None = None
407
+ path: str | None = None
408
+ files: tuple[TurnFileManifestItem, ...] = ()
409
+ answer: str | None = None
410
+ progress: TurnProgressManifest | None = None
411
+ reply: ReplyCardManifest | None = None
412
+ additions: int | None = None
413
+ deletions: int | None = None
414
+
415
+
416
+ ChannelInteraction = PromptInput | ControlIntent