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,254 @@
1
+ """Read successful patches from children created by one exact native Turn.
2
+
3
+ These bounded, best-effort reads use the public SDK and do not resume Threads.
4
+ They are a completion-time snapshot, not an atomic snapshot of the task tree.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from collections import deque
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from openai_codex import AsyncThread
16
+ from openai_codex.generated.v2_all import (
17
+ CollabAgentToolCallThreadItem,
18
+ FileChangeThreadItem,
19
+ SubAgentActivityThreadItem,
20
+ SubAgentSessionSource,
21
+ Thread,
22
+ ThreadItem,
23
+ ThreadReadResponse,
24
+ ThreadSpawnSubAgentSource,
25
+ )
26
+
27
+
28
+ _MAX_CHILDREN = 32
29
+ _READ_TIMEOUT_SECONDS = 3.0
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class TurnPatchBatch:
34
+ thread_id: str
35
+ turn_id: str
36
+ cwd: Path
37
+ items: tuple[object, ...]
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class TaskPatchChildren:
42
+ batches: tuple[TurnPatchBatch, ...] = ()
43
+ complete: bool = True
44
+
45
+
46
+ def _item(value: object) -> object:
47
+ return value.root if type(value) is ThreadItem else value
48
+
49
+
50
+ def _value(value: object) -> object:
51
+ return getattr(value, "value", value)
52
+
53
+
54
+ def _child_edges(
55
+ items: Sequence[object], parent_id: str
56
+ ) -> tuple[set[str], set[str], bool]:
57
+ spawned: set[str] = set()
58
+ referenced: set[str] = set()
59
+ complete = True
60
+ for wrapped in items:
61
+ item = _item(wrapped)
62
+ if type(item) is CollabAgentToolCallThreadItem:
63
+ tool = _value(item.tool)
64
+ # These controls neither create work nor identify a child Turn.
65
+ # listAgents can legitimately have no receivers at all.
66
+ if tool in {"closeAgent", "interruptAgent", "listAgents"}:
67
+ continue
68
+ if item.sender_thread_id != parent_id:
69
+ complete = False
70
+ continue
71
+ if _value(item.status) != "completed":
72
+ if _value(item.status) in {"inProgress", "interrupted"}:
73
+ complete = False
74
+ continue
75
+ receivers = set(item.receiver_thread_ids)
76
+ # V2 wait_agent observes mailbox activity without targeting any
77
+ # Thread. V1 waits with receivers still carry those references.
78
+ if tool == "wait" and not receivers and not item.agents_states:
79
+ continue
80
+ if not receivers or any(not receiver for receiver in receivers):
81
+ complete = False
82
+ continue
83
+ if tool == "spawnAgent":
84
+ spawned.update(receivers)
85
+ elif tool in {
86
+ "sendInput", "resumeAgent", "wait", "sendMessage", "followupTask"
87
+ }:
88
+ referenced.update(receivers)
89
+ elif type(item) is SubAgentActivityThreadItem:
90
+ if not item.agent_thread_id:
91
+ complete = False
92
+ elif _value(item.kind) == "started":
93
+ spawned.add(item.agent_thread_id)
94
+ elif _value(item.kind) in {"interacted", "completed"}:
95
+ # Completion may belong to a follow-up on an existing child.
96
+ # Its opaque item ID is not a public child Turn identity.
97
+ referenced.add(item.agent_thread_id)
98
+ return spawned, referenced, complete
99
+
100
+
101
+ def _has_parent(thread: Thread, parent_id: str) -> bool:
102
+ parents = []
103
+ if thread.parent_thread_id is not None:
104
+ parents.append(thread.parent_thread_id)
105
+ source = thread.source.root
106
+ if type(source) is SubAgentSessionSource:
107
+ child_source = source.sub_agent.root
108
+ if type(child_source) is ThreadSpawnSubAgentSource:
109
+ parents.append(child_source.thread_spawn.parent_thread_id.root)
110
+ return (
111
+ bool(parents)
112
+ and all(parent == parent_id for parent in parents)
113
+ and thread.forked_from_id in {None, parent_id}
114
+ )
115
+
116
+
117
+ async def _read_thread(codex: object, thread_id: str) -> Thread:
118
+ response = await AsyncThread(codex, thread_id).read(include_turns=True)
119
+ if type(response) is not ThreadReadResponse or response.thread.id != thread_id:
120
+ raise ValueError("native child patch read identity mismatch")
121
+ thread = response.thread
122
+ ids = [turn.id for turn in thread.turns]
123
+ if any(not turn_id for turn_id in ids) or len(set(ids)) != len(ids):
124
+ raise ValueError("native child patch read has ambiguous Turn identities")
125
+ return thread
126
+
127
+
128
+ async def collect_turn_patch_children(
129
+ codex: object,
130
+ *,
131
+ thread_id: str,
132
+ turn_id: str,
133
+ items: Sequence[object],
134
+ ) -> TaskPatchChildren:
135
+ """Collect new descendants, retaining known patches when evidence is partial.
136
+
137
+ Old children cannot be assigned to this Turn using the SDK's Thread-only
138
+ collab references. They make the result partial without importing history.
139
+ Ancestor Turn IDs exclude the history copied by native subagent forks.
140
+ """
141
+ spawned, references, complete = _child_edges(items, thread_id)
142
+ if not spawned:
143
+ return TaskPatchChildren(complete=complete and not references)
144
+
145
+ batches: list[TurnPatchBatch] = []
146
+ parents = {thread_id: ""}
147
+ pending: deque[tuple[str, str, frozenset[str]]] = deque()
148
+
149
+ def enqueue(
150
+ child_ids: set[str], parent_id: str, ancestor_turns: frozenset[str]
151
+ ) -> None:
152
+ nonlocal complete
153
+ for child_id in sorted(child_ids):
154
+ if child_id in parents:
155
+ if parents[child_id] != parent_id:
156
+ complete = False
157
+ continue
158
+ if len(parents) - 1 >= _MAX_CHILDREN:
159
+ complete = False
160
+ continue
161
+ parents[child_id] = parent_id
162
+ pending.append((child_id, parent_id, ancestor_turns))
163
+
164
+ try:
165
+ async with asyncio.timeout(_READ_TIMEOUT_SECONDS):
166
+ try:
167
+ root = await _read_thread(codex, thread_id)
168
+ except Exception:
169
+ return TaskPatchChildren(complete=False)
170
+ root_ids = [turn.id for turn in root.turns]
171
+ # A newer root Turn may already have sent another task to the same
172
+ # child. Its child Turn identity is not exposed by this SDK.
173
+ # Root items are already frozen by the caller. Here only Turn IDs
174
+ # are used; items_view describes item contents, not those identities.
175
+ if not root_ids or root_ids[-1] != turn_id:
176
+ return TaskPatchChildren(complete=False)
177
+ enqueue(spawned, thread_id, frozenset(root_ids))
178
+ while pending:
179
+ child_id, parent_id, ancestor_turns = pending.popleft()
180
+ try:
181
+ child = await _read_thread(codex, child_id)
182
+ if not _has_parent(child, parent_id):
183
+ raise ValueError("native child patch read parent mismatch")
184
+ cwd = Path(child.cwd.root)
185
+ if not cwd.is_absolute():
186
+ raise ValueError("native child patch read cwd is not absolute")
187
+ except Exception:
188
+ complete = False
189
+ continue
190
+ own_turns = [
191
+ turn for turn in child.turns if turn.id not in ancestor_turns
192
+ ]
193
+ if not own_turns:
194
+ complete = False
195
+ child_ancestors = ancestor_turns | frozenset(
196
+ turn.id for turn in child.turns
197
+ )
198
+ for turn in own_turns:
199
+ if (
200
+ _value(turn.status) == "inProgress"
201
+ or _value(turn.items_view) != "full"
202
+ ):
203
+ complete = False
204
+ patches = {}
205
+ conflicting = set()
206
+ for wrapped in turn.items:
207
+ item = _item(wrapped)
208
+ if type(item) is not FileChangeThreadItem:
209
+ continue
210
+ if _value(item.status) == "inProgress":
211
+ complete = False
212
+ elif _value(item.status) == "completed":
213
+ if item.id in conflicting:
214
+ continue
215
+ if item.id in patches and patches[item.id] != item:
216
+ complete = False
217
+ conflicting.add(item.id)
218
+ del patches[item.id]
219
+ continue
220
+ patches[item.id] = item
221
+ if patches:
222
+ batches.append(
223
+ TurnPatchBatch(
224
+ child_id, turn.id, cwd, tuple(patches.values())
225
+ )
226
+ )
227
+ new_children, related, edges_complete = _child_edges(
228
+ turn.items, child_id
229
+ )
230
+ complete = complete and edges_complete
231
+ references.update(related)
232
+ enqueue(new_children, child_id, child_ancestors)
233
+ # Admission may open while this best-effort display read is running.
234
+ # A new root Turn can follow up with these same children, so validate
235
+ # the boundary again before publishing any child evidence.
236
+ if len(parents) > 1:
237
+ try:
238
+ final_root = await _read_thread(codex, thread_id)
239
+ except Exception:
240
+ return TaskPatchChildren(complete=False)
241
+ if not final_root.turns or final_root.turns[-1].id != turn_id:
242
+ return TaskPatchChildren(complete=False)
243
+ except TimeoutError:
244
+ # Without the closing root read, earlier successful child reads may
245
+ # already include work from a newly admitted root Turn.
246
+ return TaskPatchChildren(complete=False)
247
+ # Resolve references after the traversal: siblings may communicate before
248
+ # their spawn item is encountered in another member of this task tree.
249
+ # A reply to this root is also known: both root reads proved the exact Turn,
250
+ # whose patches the caller already froze. Never import root history here.
251
+ return TaskPatchChildren(
252
+ batches=tuple(batches),
253
+ complete=complete and references.issubset(parents),
254
+ )
@@ -0,0 +1,315 @@
1
+ """Pinned, non-consuming observation of one active Turn's retained activity.
2
+
3
+ This is the complete private-SDK exception approved by ADR 0020 and extended
4
+ by ADR 0052. It never registers, consumes, or mutates notifications and
5
+ exposes no generic queue or RPC access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Protocol
14
+
15
+ import openai_codex
16
+ from openai_codex import AsyncCodex
17
+ from openai_codex._message_router import MessageRouter, _TurnState
18
+ from openai_codex.async_client import AsyncCodexClient
19
+ from openai_codex.client import CodexClient
20
+ from openai_codex.generated.v2_all import (
21
+ ItemCompletedNotification,
22
+ ItemStartedNotification,
23
+ TurnCompletedNotification,
24
+ TurnPlanStep,
25
+ TurnPlanStepStatus,
26
+ TurnPlanUpdatedNotification,
27
+ TurnStartedNotification,
28
+ )
29
+ from openai_codex.models import Notification
30
+
31
+ from .terminal_cleanup import _source_tree_fingerprint
32
+ from .user_questions import QuestionRequest
33
+ from .turn_activity import (
34
+ TurnActivityEvent,
35
+ TurnActivityProjectionUnavailable,
36
+ TurnPlanStepSnapshot,
37
+ TurnPlanStepState,
38
+ project_turn_activity_notification,
39
+ )
40
+
41
+
42
+ SUPPORTED_SDK_VERSION = "0.156.1"
43
+ _PACKAGE_SOURCE_FINGERPRINT = (
44
+ "7d0a2267e45d39934c64d2c01bddde8b36eacb2d771dd6903a056c6abef8e94f"
45
+ )
46
+ _LOCK_TYPE = type(threading.RLock())
47
+
48
+
49
+ class TurnActivityObservationUnavailable(RuntimeError):
50
+ """The pinned read-only observation contract cannot be satisfied."""
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class TurnActivityObservation:
55
+ next_cursor: int
56
+ plan_updated: bool
57
+ plan_cursor: int | None = None
58
+ steps: tuple[TurnPlanStepSnapshot, ...] = ()
59
+ events: tuple[TurnActivityEvent, ...] = ()
60
+ turn_completed: bool = False
61
+ retained_count: int = 0
62
+ questions: tuple[QuestionRequest, ...] = ()
63
+
64
+ def __post_init__(self) -> None:
65
+ if self.next_cursor < 0:
66
+ raise ValueError("plan observation cursor must be non-negative")
67
+ if (
68
+ type(self.retained_count) is not int
69
+ or not 0 <= self.retained_count <= self.next_cursor
70
+ ):
71
+ raise ValueError("retained notification count must fall within the cursor")
72
+ if self.plan_updated != (self.plan_cursor is not None):
73
+ raise ValueError("plan cursor must identify exactly one plan update")
74
+ if self.plan_cursor is not None and not 0 < self.plan_cursor <= self.next_cursor:
75
+ raise ValueError("plan update cursor must fall within the observation")
76
+
77
+
78
+ class TurnActivityObserver(Protocol):
79
+ def observe(
80
+ self,
81
+ *,
82
+ thread_id: str,
83
+ turn_id: str,
84
+ after_cursor: int,
85
+ ) -> TurnActivityObservation: ...
86
+
87
+
88
+ class PinnedTurnActivityObserver:
89
+ """Peek at already-routed safe activity without consuming notifications."""
90
+
91
+ __slots__ = ("_router",)
92
+
93
+ def __init__(self, codex: AsyncCodex) -> None:
94
+ self._router = _validate_contract(codex)
95
+
96
+ def observe(
97
+ self,
98
+ *,
99
+ thread_id: str,
100
+ turn_id: str,
101
+ after_cursor: int,
102
+ ) -> TurnActivityObservation:
103
+ _validate_exact_id(thread_id, label="Thread")
104
+ _validate_exact_id(turn_id, label="Turn")
105
+ if isinstance(after_cursor, bool) or not isinstance(after_cursor, int):
106
+ raise ValueError("activity cursor must be an integer")
107
+ if after_cursor < 0:
108
+ raise ValueError("activity cursor must be non-negative")
109
+
110
+ items, next_cursor, retained_count = self._snapshot_events(
111
+ thread_id, turn_id, after_cursor
112
+ )
113
+ latest_steps: tuple[TurnPlanStepSnapshot, ...] = ()
114
+ latest_cursor: int | None = None
115
+ events: list[TurnActivityEvent] = []
116
+ questions: list[QuestionRequest] = []
117
+ turn_completed = False
118
+ for item_cursor, item in enumerate(items, start=after_cursor + 1):
119
+ if isinstance(item, BaseException):
120
+ raise TurnActivityObservationUnavailable(
121
+ "native Turn event store contains a transport failure"
122
+ )
123
+ if type(item) is not Notification:
124
+ raise TurnActivityObservationUnavailable(
125
+ "native Turn notification item shape changed"
126
+ )
127
+ try:
128
+ projection = project_turn_activity_notification(
129
+ item,
130
+ expected_thread_id=thread_id,
131
+ expected_turn_id=turn_id,
132
+ )
133
+ except TurnActivityProjectionUnavailable as error:
134
+ raise TurnActivityObservationUnavailable(str(error)) from error
135
+ if projection.plan_updated:
136
+ latest_steps = projection.steps
137
+ latest_cursor = item_cursor
138
+ if projection.event is not None:
139
+ events.append(projection.event)
140
+ if projection.question is not None:
141
+ questions.append(projection.question)
142
+ turn_completed = turn_completed or projection.turn_completed
143
+
144
+ return TurnActivityObservation(
145
+ next_cursor=next_cursor,
146
+ plan_updated=latest_cursor is not None,
147
+ plan_cursor=latest_cursor,
148
+ steps=latest_steps,
149
+ events=tuple(events),
150
+ turn_completed=turn_completed,
151
+ retained_count=retained_count,
152
+ questions=tuple(questions),
153
+ )
154
+
155
+ def _snapshot_events(
156
+ self,
157
+ thread_id: str,
158
+ turn_id: str,
159
+ after_cursor: int,
160
+ ) -> tuple[tuple[object, ...], int, int]:
161
+ router = self._router
162
+ lock = getattr(router, "_lock", None)
163
+ states = getattr(router, "_turn_states", None)
164
+ if type(lock) is not _LOCK_TYPE or type(states) is not dict:
165
+ raise TurnActivityObservationUnavailable(
166
+ "native notification router shape changed"
167
+ )
168
+ with lock:
169
+ state = states.get(turn_id)
170
+ if type(state) is not _TurnState:
171
+ raise TurnActivityObservationUnavailable(
172
+ "exact native Turn event store is unavailable"
173
+ )
174
+ if (
175
+ getattr(state, "id", None) != turn_id
176
+ or getattr(state, "thread_id", None) != thread_id
177
+ ):
178
+ raise TurnActivityObservationUnavailable(
179
+ "native Turn event store identity changed"
180
+ )
181
+ raw_items = getattr(state, "events", None)
182
+ first_cursor = getattr(state, "first_event", None)
183
+ next_cursor = getattr(state, "next_event", None)
184
+ subscribers = getattr(state, "subscribers", None)
185
+ if (
186
+ type(raw_items) is not dict
187
+ or type(first_cursor) is not int
188
+ or type(next_cursor) is not int
189
+ or not 0 <= first_cursor <= next_cursor
190
+ or type(getattr(state, "completed", None)) is not bool
191
+ or type(subscribers) is not dict
192
+ or not subscribers
193
+ or any(
194
+ type(cursor) is not int
195
+ or not first_cursor <= cursor <= next_cursor
196
+ for cursor in subscribers.values()
197
+ )
198
+ ):
199
+ raise TurnActivityObservationUnavailable(
200
+ "native Turn event store shape changed"
201
+ )
202
+ retained_count = len(raw_items)
203
+ if retained_count != next_cursor - first_cursor or any(
204
+ type(cursor) is not int or not first_cursor <= cursor < next_cursor
205
+ for cursor in raw_items
206
+ ):
207
+ raise TurnActivityObservationUnavailable(
208
+ "native Turn event store has a cursor gap"
209
+ )
210
+ if after_cursor < first_cursor:
211
+ raise TurnActivityObservationUnavailable(
212
+ "native Turn events were pruned before the observation cursor"
213
+ )
214
+ if after_cursor > next_cursor:
215
+ raise TurnActivityObservationUnavailable(
216
+ "native Turn event cursor moved backwards"
217
+ )
218
+ # Cursor positions are absolute event indices. Copy only references;
219
+ # the SDK subscriptions retain exclusive consumption/pruning rights.
220
+ items = tuple(raw_items[cursor] for cursor in range(after_cursor, next_cursor))
221
+ return items, next_cursor, retained_count
222
+
223
+
224
+ def _validate_contract(codex: AsyncCodex) -> MessageRouter:
225
+ if openai_codex.__version__ != SUPPORTED_SDK_VERSION:
226
+ raise TurnActivityObservationUnavailable(
227
+ "Turn activity observation supports only openai-codex=="
228
+ f"{SUPPORTED_SDK_VERSION}; found {openai_codex.__version__}"
229
+ )
230
+ if type(codex) is not AsyncCodex:
231
+ raise TurnActivityObservationUnavailable("AsyncCodex implementation type changed")
232
+ if getattr(codex, "_initialized", False) is not True:
233
+ raise TurnActivityObservationUnavailable(
234
+ "AsyncCodex must be initialized before Turn activity observation is enabled"
235
+ )
236
+ client = getattr(codex, "_client", None)
237
+ if type(client) is not AsyncCodexClient:
238
+ raise TurnActivityObservationUnavailable("AsyncCodex private client shape changed")
239
+ sync_client = getattr(client, "_sync", None)
240
+ if type(sync_client) is not CodexClient:
241
+ raise TurnActivityObservationUnavailable(
242
+ "AsyncCodexClient private sync client shape changed"
243
+ )
244
+ router = getattr(sync_client, "_router", None)
245
+ if type(router) is not MessageRouter:
246
+ raise TurnActivityObservationUnavailable("Codex notification router shape changed")
247
+ if type(getattr(router, "_lock", None)) is not _LOCK_TYPE:
248
+ raise TurnActivityObservationUnavailable("Codex notification router lock changed")
249
+ if type(getattr(router, "_turn_states", None)) is not dict:
250
+ raise TurnActivityObservationUnavailable("Codex Turn route catalog shape changed")
251
+
252
+ _validate_generated_models()
253
+ package_file = getattr(openai_codex, "__file__", None)
254
+ if not isinstance(package_file, str) or not package_file.endswith(".py"):
255
+ raise TurnActivityObservationUnavailable("cannot locate SDK source package")
256
+ try:
257
+ actual = _source_tree_fingerprint(Path(package_file).resolve().parent)
258
+ except OSError as error:
259
+ raise TurnActivityObservationUnavailable(
260
+ "cannot read SDK source package"
261
+ ) from error
262
+ if actual != _PACKAGE_SOURCE_FINGERPRINT:
263
+ raise TurnActivityObservationUnavailable(
264
+ "SDK package source fingerprint changed"
265
+ )
266
+ return router
267
+
268
+
269
+ def _validate_generated_models() -> None:
270
+ payload_fields = getattr(TurnPlanUpdatedNotification, "model_fields", {})
271
+ if set(payload_fields) != {"explanation", "plan", "thread_id", "turn_id"}:
272
+ raise TurnActivityObservationUnavailable("Turn plan payload fields changed")
273
+ if {
274
+ name: getattr(payload_fields.get(name), "alias", None)
275
+ for name in ("explanation", "thread_id", "turn_id", "plan")
276
+ } != {
277
+ "explanation": None,
278
+ "thread_id": "threadId",
279
+ "turn_id": "turnId",
280
+ "plan": None,
281
+ }:
282
+ raise TurnActivityObservationUnavailable("Turn plan payload fields changed")
283
+ if getattr(payload_fields["explanation"], "default", object()) is not None:
284
+ raise TurnActivityObservationUnavailable("Turn plan explanation default changed")
285
+ step_fields = getattr(TurnPlanStep, "model_fields", {})
286
+ if set(step_fields) != {"step", "status"}:
287
+ raise TurnActivityObservationUnavailable("Turn plan step fields changed")
288
+ if {member.value for member in TurnPlanStepStatus} != {
289
+ "pending",
290
+ "inProgress",
291
+ "completed",
292
+ }:
293
+ raise TurnActivityObservationUnavailable("Turn plan step statuses changed")
294
+
295
+ expected_notification_fields = {
296
+ ItemStartedNotification: {"item", "started_at_ms", "thread_id", "turn_id"},
297
+ ItemCompletedNotification: {
298
+ "completed_at_ms",
299
+ "item",
300
+ "thread_id",
301
+ "turn_id",
302
+ },
303
+ TurnStartedNotification: {"thread_id", "turn"},
304
+ TurnCompletedNotification: {"thread_id", "turn"},
305
+ }
306
+ for model, expected_fields in expected_notification_fields.items():
307
+ if set(getattr(model, "model_fields", {})) != expected_fields:
308
+ raise TurnActivityObservationUnavailable(
309
+ f"{model.__name__} fields changed"
310
+ )
311
+
312
+
313
+ def _validate_exact_id(value: str, *, label: str) -> None:
314
+ if not isinstance(value, str) or not value or value.strip() != value:
315
+ raise ValueError(f"native {label} ID must be a non-empty trimmed string")
@@ -0,0 +1,106 @@
1
+ """Native question display data and Codex 0.156.1 answer formatting.
2
+
3
+ These are presentation values, not pending requests or a question lifecycle.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ from dataclasses import dataclass
11
+
12
+
13
+ _TARGET_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,127}")
14
+
15
+
16
+ def _validate_target_id(value: str) -> None:
17
+ if not isinstance(value, str) or _TARGET_ID.fullmatch(value) is None:
18
+ raise ValueError("question target requires an exact Binding or Side identity")
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class BindingQuestionTarget:
23
+ binding_id: str
24
+
25
+ def __post_init__(self) -> None:
26
+ _validate_target_id(self.binding_id)
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class SideQuestionTarget:
31
+ side_id: str
32
+
33
+ def __post_init__(self) -> None:
34
+ _validate_target_id(self.side_id)
35
+
36
+
37
+ QuestionTarget = BindingQuestionTarget | SideQuestionTarget
38
+
39
+
40
+ def question_target_payload(target: QuestionTarget) -> dict[str, str]:
41
+ """Reference an existing input target without introducing another identity."""
42
+
43
+ if isinstance(target, BindingQuestionTarget):
44
+ return {"kind": "binding", "id": target.binding_id}
45
+ if isinstance(target, SideQuestionTarget):
46
+ return {"kind": "side", "id": target.side_id}
47
+ raise TypeError("question target must reference a Binding or Side")
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class UserQuestion:
52
+ title: str
53
+ options: tuple[str, ...] = ()
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class QuestionRequest:
58
+ item_id: str
59
+ questions: tuple[UserQuestion, ...]
60
+
61
+
62
+ def question_item_id(item_id: str, question_index: int) -> str:
63
+ """Match the official client's identity for one question in an item."""
64
+
65
+ return json.dumps(
66
+ ["request_user_input_async", item_id, question_index],
67
+ ensure_ascii=False,
68
+ separators=(",", ":"),
69
+ )
70
+
71
+
72
+ def format_question_answer(
73
+ item_id: str, question_index: int, title: str, answer: str
74
+ ) -> str:
75
+ """Render the native 0.156.1 answered-question fragment.
76
+
77
+ The title's UTF-8 byte bound and overlong-identity fallback layout match
78
+ context-fragments/src/answered_question.rs. Model-authored dollar signs use
79
+ Unicode escapes so native raw-text scanning cannot turn them into Skills.
80
+ In the plain fallback these remain readable literal escape sequences.
81
+ """
82
+
83
+ question_id = question_item_id(item_id, question_index)
84
+ bounded_title = title.encode("utf-8")[:512].decode("utf-8", errors="ignore")
85
+ bounded_title = bounded_title.replace("\n", " ").replace("\r", " ")
86
+ if len(question_id.encode("utf-8")) > 512:
87
+ inert_title = bounded_title.replace("$", "\\u0024")
88
+ return f"> {inert_title}\n\n{answer}"
89
+
90
+ def model_text(value: str) -> str:
91
+ return json.dumps(value, ensure_ascii=False).replace("$", "\\u0024")
92
+
93
+ body = (
94
+ '[{"answer":'
95
+ + json.dumps(answer, ensure_ascii=False)
96
+ + ',"question":'
97
+ + model_text(bounded_title)
98
+ + ',"questionItemId":'
99
+ + model_text(question_id)
100
+ + "}]"
101
+ )
102
+ return (
103
+ "<send_user_message_question_reply>\n"
104
+ + body
105
+ + "\n</send_user_message_question_reply>"
106
+ )
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: netizen-cli
3
+ Version: 0.10.0
4
+ Summary: Use native Codex threads through a Feishu Channel.
5
+ Requires-Python: <3.15,>=3.11
6
+ Requires-Dist: h11==0.16.0
7
+ Requires-Dist: lark-channel-sdk==1.4.0
8
+ Requires-Dist: lark-oapi==1.7.2
9
+ Requires-Dist: markdown-it-py==4.0.0
10
+ Requires-Dist: mdformat==1.0.0
11
+ Requires-Dist: mdformat-gfm==1.0.0
12
+ Requires-Dist: mcp==2.2.0
13
+ Requires-Dist: openai-codex==0.156.1
14
+ Requires-Dist: pydantic==2.13.4
15
+ Requires-Dist: PyYAML==6.0.3
16
+ Requires-Dist: qrcode==8.2
17
+ Requires-Dist: truststore==0.10.4; sys_platform == "darwin"
18
+ Requires-Dist: uvicorn==0.52.4