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,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
|