yee88 0.3.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 (103) hide show
  1. yee88/__init__.py +1 -0
  2. yee88/api.py +116 -0
  3. yee88/backends.py +25 -0
  4. yee88/backends_helpers.py +14 -0
  5. yee88/cli/__init__.py +228 -0
  6. yee88/cli/config.py +320 -0
  7. yee88/cli/doctor.py +173 -0
  8. yee88/cli/init.py +113 -0
  9. yee88/cli/onboarding_cmd.py +126 -0
  10. yee88/cli/plugins.py +196 -0
  11. yee88/cli/run.py +419 -0
  12. yee88/cli/topic.py +355 -0
  13. yee88/commands.py +134 -0
  14. yee88/config.py +142 -0
  15. yee88/config_migrations.py +124 -0
  16. yee88/config_watch.py +146 -0
  17. yee88/context.py +9 -0
  18. yee88/directives.py +146 -0
  19. yee88/engines.py +53 -0
  20. yee88/events.py +170 -0
  21. yee88/ids.py +17 -0
  22. yee88/lockfile.py +158 -0
  23. yee88/logging.py +283 -0
  24. yee88/markdown.py +298 -0
  25. yee88/model.py +77 -0
  26. yee88/plugins.py +312 -0
  27. yee88/presenter.py +25 -0
  28. yee88/progress.py +99 -0
  29. yee88/router.py +113 -0
  30. yee88/runner.py +712 -0
  31. yee88/runner_bridge.py +619 -0
  32. yee88/runners/__init__.py +1 -0
  33. yee88/runners/claude.py +483 -0
  34. yee88/runners/codex.py +656 -0
  35. yee88/runners/mock.py +221 -0
  36. yee88/runners/opencode.py +505 -0
  37. yee88/runners/pi.py +523 -0
  38. yee88/runners/run_options.py +39 -0
  39. yee88/runners/tool_actions.py +90 -0
  40. yee88/runtime_loader.py +207 -0
  41. yee88/scheduler.py +159 -0
  42. yee88/schemas/__init__.py +1 -0
  43. yee88/schemas/claude.py +238 -0
  44. yee88/schemas/codex.py +169 -0
  45. yee88/schemas/opencode.py +51 -0
  46. yee88/schemas/pi.py +117 -0
  47. yee88/settings.py +360 -0
  48. yee88/telegram/__init__.py +20 -0
  49. yee88/telegram/api_models.py +37 -0
  50. yee88/telegram/api_schemas.py +152 -0
  51. yee88/telegram/backend.py +163 -0
  52. yee88/telegram/bridge.py +425 -0
  53. yee88/telegram/chat_prefs.py +242 -0
  54. yee88/telegram/chat_sessions.py +112 -0
  55. yee88/telegram/client.py +409 -0
  56. yee88/telegram/client_api.py +539 -0
  57. yee88/telegram/commands/__init__.py +12 -0
  58. yee88/telegram/commands/agent.py +196 -0
  59. yee88/telegram/commands/cancel.py +116 -0
  60. yee88/telegram/commands/dispatch.py +111 -0
  61. yee88/telegram/commands/executor.py +449 -0
  62. yee88/telegram/commands/file_transfer.py +586 -0
  63. yee88/telegram/commands/handlers.py +45 -0
  64. yee88/telegram/commands/media.py +143 -0
  65. yee88/telegram/commands/menu.py +139 -0
  66. yee88/telegram/commands/model.py +215 -0
  67. yee88/telegram/commands/overrides.py +159 -0
  68. yee88/telegram/commands/parse.py +30 -0
  69. yee88/telegram/commands/plan.py +16 -0
  70. yee88/telegram/commands/reasoning.py +234 -0
  71. yee88/telegram/commands/reply.py +23 -0
  72. yee88/telegram/commands/topics.py +332 -0
  73. yee88/telegram/commands/trigger.py +143 -0
  74. yee88/telegram/context.py +140 -0
  75. yee88/telegram/engine_defaults.py +86 -0
  76. yee88/telegram/engine_overrides.py +105 -0
  77. yee88/telegram/files.py +178 -0
  78. yee88/telegram/loop.py +1822 -0
  79. yee88/telegram/onboarding.py +1088 -0
  80. yee88/telegram/outbox.py +177 -0
  81. yee88/telegram/parsing.py +239 -0
  82. yee88/telegram/render.py +198 -0
  83. yee88/telegram/state_store.py +88 -0
  84. yee88/telegram/topic_state.py +334 -0
  85. yee88/telegram/topics.py +256 -0
  86. yee88/telegram/trigger_mode.py +68 -0
  87. yee88/telegram/types.py +63 -0
  88. yee88/telegram/voice.py +110 -0
  89. yee88/transport.py +53 -0
  90. yee88/transport_runtime.py +323 -0
  91. yee88/transports.py +76 -0
  92. yee88/utils/__init__.py +1 -0
  93. yee88/utils/git.py +87 -0
  94. yee88/utils/json_state.py +21 -0
  95. yee88/utils/paths.py +47 -0
  96. yee88/utils/streams.py +44 -0
  97. yee88/utils/subprocess.py +86 -0
  98. yee88/worktrees.py +135 -0
  99. yee88-0.3.0.dist-info/METADATA +116 -0
  100. yee88-0.3.0.dist-info/RECORD +103 -0
  101. yee88-0.3.0.dist-info/WHEEL +4 -0
  102. yee88-0.3.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,207 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+ from collections.abc import Iterable, Mapping
8
+
9
+ from .backends import EngineBackend
10
+ from .config import ConfigError, ProjectsConfig
11
+ from .engines import get_backend, list_backend_ids
12
+ from .ids import RESERVED_CHAT_COMMANDS
13
+ from .logging import get_logger
14
+ from .router import AutoRouter, EngineStatus, RunnerEntry
15
+ from .settings import TakopiSettings
16
+ from .transport_runtime import TransportRuntime
17
+
18
+ logger = get_logger(__name__)
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class RuntimeSpec:
23
+ router: AutoRouter
24
+ projects: ProjectsConfig
25
+ allowlist: list[str] | None
26
+ plugin_configs: Mapping[str, Any] | None
27
+ watch_config: bool = False
28
+
29
+ def to_runtime(self, *, config_path: Path | None) -> TransportRuntime:
30
+ return TransportRuntime(
31
+ router=self.router,
32
+ projects=self.projects,
33
+ allowlist=self.allowlist,
34
+ config_path=config_path,
35
+ plugin_configs=self.plugin_configs,
36
+ watch_config=self.watch_config,
37
+ )
38
+
39
+ def apply(self, runtime: TransportRuntime, *, config_path: Path | None) -> None:
40
+ runtime.update(
41
+ router=self.router,
42
+ projects=self.projects,
43
+ allowlist=self.allowlist,
44
+ config_path=config_path,
45
+ plugin_configs=self.plugin_configs,
46
+ watch_config=self.watch_config,
47
+ )
48
+
49
+
50
+ def resolve_plugins_allowlist(
51
+ settings: TakopiSettings | None,
52
+ ) -> list[str] | None:
53
+ if settings is None:
54
+ return None
55
+ enabled = list(settings.plugins.enabled)
56
+ return enabled or None
57
+
58
+
59
+ def resolve_default_engine(
60
+ *,
61
+ override: str | None,
62
+ settings: TakopiSettings,
63
+ config_path: Path,
64
+ engine_ids: list[str],
65
+ ) -> str:
66
+ default_engine = override or settings.default_engine or "codex"
67
+ if default_engine not in engine_ids:
68
+ available = ", ".join(sorted(engine_ids))
69
+ raise ConfigError(
70
+ f"Unknown default engine {default_engine!r}. Available: {available}."
71
+ )
72
+ return default_engine
73
+
74
+
75
+ def build_router(
76
+ *,
77
+ settings: TakopiSettings,
78
+ config_path: Path,
79
+ backends: list[EngineBackend],
80
+ default_engine: str,
81
+ ) -> AutoRouter:
82
+ entries: list[RunnerEntry] = []
83
+ warnings: list[str] = []
84
+
85
+ for backend in backends:
86
+ engine_id = backend.id
87
+ issue: str | None = None
88
+ status: EngineStatus = "ok"
89
+ engine_cfg: dict
90
+ try:
91
+ engine_cfg = settings.engine_config(engine_id, config_path=config_path)
92
+ except ConfigError as exc:
93
+ if engine_id == default_engine:
94
+ raise
95
+ issue = str(exc)
96
+ status = "bad_config"
97
+ engine_cfg = {}
98
+
99
+ try:
100
+ runner = backend.build_runner(engine_cfg, config_path)
101
+ except Exception as exc:
102
+ if engine_id == default_engine:
103
+ raise
104
+ issue = issue or str(exc)
105
+ if engine_cfg:
106
+ try:
107
+ runner = backend.build_runner({}, config_path)
108
+ except Exception as fallback_exc: # noqa: BLE001
109
+ warnings.append(f"{engine_id}: {issue or str(fallback_exc)}")
110
+ continue
111
+ status = "bad_config"
112
+ else:
113
+ status = "load_error"
114
+ warnings.append(f"{engine_id}: {issue}")
115
+ continue
116
+
117
+ cmd = backend.cli_cmd or backend.id
118
+ if shutil.which(cmd) is None:
119
+ status = "missing_cli"
120
+ if issue:
121
+ issue = f"{issue}; {cmd} not found on PATH"
122
+ else:
123
+ issue = f"{cmd} not found on PATH"
124
+
125
+ if status != "ok" and engine_id == default_engine:
126
+ raise ConfigError(f"Default engine {engine_id!r} unavailable: {issue}")
127
+
128
+ if status != "ok" and engine_id != default_engine:
129
+ warnings.append(f"{engine_id}: {issue}")
130
+
131
+ entries.append(
132
+ RunnerEntry(
133
+ engine=engine_id,
134
+ runner=runner,
135
+ status=status,
136
+ issue=issue,
137
+ )
138
+ )
139
+
140
+ for warning in warnings:
141
+ logger.warning("setup.warning", issue=warning)
142
+
143
+ return AutoRouter(entries=entries, default_engine=default_engine)
144
+
145
+
146
+ def load_backends(
147
+ *,
148
+ engine_ids: list[str],
149
+ allowlist: list[str] | None,
150
+ default_engine: str,
151
+ ) -> list[EngineBackend]:
152
+ backends: list[EngineBackend] = []
153
+ load_issues: list[str] = []
154
+ for engine_id in engine_ids:
155
+ try:
156
+ backend = get_backend(engine_id, allowlist=allowlist)
157
+ except ConfigError as exc:
158
+ if engine_id == default_engine:
159
+ raise
160
+ load_issues.append(f"{engine_id}: {exc}")
161
+ continue
162
+ backends.append(backend)
163
+ if not backends:
164
+ raise ConfigError("No engine backends are available.")
165
+ for issue in load_issues:
166
+ logger.warning("setup.warning", issue=issue)
167
+ return backends
168
+
169
+
170
+ def build_runtime_spec(
171
+ *,
172
+ settings: TakopiSettings,
173
+ config_path: Path,
174
+ default_engine_override: str | None = None,
175
+ reserved: Iterable[str] = RESERVED_CHAT_COMMANDS,
176
+ ) -> RuntimeSpec:
177
+ allowlist = resolve_plugins_allowlist(settings)
178
+ engine_ids = list_backend_ids(allowlist=allowlist)
179
+ projects = settings.to_projects_config(
180
+ config_path=config_path,
181
+ engine_ids=engine_ids,
182
+ reserved=reserved,
183
+ )
184
+ default_engine = resolve_default_engine(
185
+ override=default_engine_override,
186
+ settings=settings,
187
+ config_path=config_path,
188
+ engine_ids=engine_ids,
189
+ )
190
+ backends = load_backends(
191
+ engine_ids=engine_ids,
192
+ allowlist=allowlist,
193
+ default_engine=default_engine,
194
+ )
195
+ router = build_router(
196
+ settings=settings,
197
+ config_path=config_path,
198
+ backends=backends,
199
+ default_engine=default_engine,
200
+ )
201
+ return RuntimeSpec(
202
+ router=router,
203
+ projects=projects,
204
+ allowlist=allowlist,
205
+ plugin_configs=settings.plugins.model_extra,
206
+ watch_config=settings.watch_config,
207
+ )
yee88/scheduler.py ADDED
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from dataclasses import dataclass
5
+ from typing import Any, Protocol
6
+ from collections.abc import Awaitable, Callable
7
+
8
+ import anyio
9
+
10
+ from .context import RunContext
11
+ from .logging import get_logger
12
+ from .model import ResumeToken
13
+ from .transport import ChannelId, MessageId, MessageRef, ThreadId
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class ThreadJob:
20
+ chat_id: ChannelId
21
+ user_msg_id: MessageId
22
+ text: str
23
+ resume_token: ResumeToken
24
+ context: RunContext | None = None
25
+ thread_id: ThreadId | None = None
26
+ session_key: tuple[int, int | None] | None = None
27
+ progress_ref: MessageRef | None = None
28
+
29
+
30
+ RunJob = Callable[[ThreadJob], Awaitable[None]]
31
+
32
+
33
+ class TaskGroup(Protocol):
34
+ def start_soon(
35
+ self, func: Callable[..., Awaitable[object]], *args: Any
36
+ ) -> None: ...
37
+
38
+
39
+ class ThreadScheduler:
40
+ def __init__(self, *, task_group: TaskGroup, run_job: RunJob) -> None:
41
+ self._task_group = task_group
42
+ self._run_job = run_job
43
+ self._lock = anyio.Lock()
44
+ self._pending_by_thread: dict[str, deque[ThreadJob]] = {}
45
+ self._queued_by_progress: dict[tuple[ChannelId, MessageId], ThreadJob] = {}
46
+ self._active_threads: set[str] = set()
47
+ self._busy_until: dict[str, anyio.Event] = {}
48
+
49
+ @staticmethod
50
+ def thread_key(token: ResumeToken) -> str:
51
+ return f"{token.engine}:{token.value}"
52
+
53
+ async def note_thread_known(self, token: ResumeToken, done: anyio.Event) -> None:
54
+ key = self.thread_key(token)
55
+ async with self._lock:
56
+ current = self._busy_until.get(key)
57
+ if current is None or current.is_set():
58
+ self._busy_until[key] = done
59
+ self._task_group.start_soon(self._clear_busy, key, done)
60
+
61
+ async def enqueue(self, job: ThreadJob) -> None:
62
+ key = self.thread_key(job.resume_token)
63
+ async with self._lock:
64
+ queue = self._pending_by_thread.get(key)
65
+ if queue is None:
66
+ queue = deque()
67
+ self._pending_by_thread[key] = queue
68
+ queue.append(job)
69
+ if job.progress_ref is not None:
70
+ progress_key = (job.chat_id, job.progress_ref.message_id)
71
+ self._queued_by_progress[progress_key] = job
72
+ if key in self._active_threads:
73
+ return
74
+ self._active_threads.add(key)
75
+ self._task_group.start_soon(self._thread_worker, key)
76
+
77
+ async def enqueue_resume(
78
+ self,
79
+ chat_id: ChannelId,
80
+ user_msg_id: MessageId,
81
+ text: str,
82
+ resume_token: ResumeToken,
83
+ context: RunContext | None = None,
84
+ thread_id: ThreadId | None = None,
85
+ session_key: tuple[int, int | None] | None = None,
86
+ progress_ref: MessageRef | None = None,
87
+ ) -> None:
88
+ await self.enqueue(
89
+ ThreadJob(
90
+ chat_id=chat_id,
91
+ user_msg_id=user_msg_id,
92
+ text=text,
93
+ resume_token=resume_token,
94
+ context=context,
95
+ thread_id=thread_id,
96
+ session_key=session_key,
97
+ progress_ref=progress_ref,
98
+ )
99
+ )
100
+
101
+ async def cancel_queued(
102
+ self, chat_id: ChannelId, progress_msg_id: MessageId
103
+ ) -> ThreadJob | None:
104
+ progress_key = (chat_id, progress_msg_id)
105
+ async with self._lock:
106
+ job = self._queued_by_progress.pop(progress_key, None)
107
+ if job is None:
108
+ return None
109
+ thread_key = self.thread_key(job.resume_token)
110
+ queue = self._pending_by_thread.get(thread_key)
111
+ if queue is None:
112
+ return None
113
+ try:
114
+ queue.remove(job)
115
+ except ValueError:
116
+ return None
117
+ if not queue:
118
+ self._pending_by_thread.pop(thread_key, None)
119
+ return job
120
+
121
+ async def _clear_busy(self, key: str, done: anyio.Event) -> None:
122
+ await done.wait()
123
+ async with self._lock:
124
+ if self._busy_until.get(key) is done:
125
+ self._busy_until.pop(key, None)
126
+
127
+ async def _thread_worker(self, key: str) -> None:
128
+ try:
129
+ while True:
130
+ async with self._lock:
131
+ done = self._busy_until.get(key)
132
+ queue = self._pending_by_thread.get(key)
133
+ if not queue:
134
+ self._pending_by_thread.pop(key, None)
135
+ self._active_threads.discard(key)
136
+ return
137
+ job = queue.popleft()
138
+ if job.progress_ref is not None:
139
+ progress_key = (job.chat_id, job.progress_ref.message_id)
140
+ self._queued_by_progress.pop(progress_key, None)
141
+
142
+ if done is not None and not done.is_set():
143
+ await done.wait()
144
+
145
+ try:
146
+ await self._run_job(job)
147
+ except Exception as exc: # noqa: BLE001
148
+ logger.exception(
149
+ "scheduler.job_failed",
150
+ key=key,
151
+ tag=job.resume_token.engine,
152
+ chat_id=job.chat_id,
153
+ user_msg_id=job.user_msg_id,
154
+ error=str(exc),
155
+ error_type=exc.__class__.__name__,
156
+ )
157
+ finally:
158
+ async with self._lock:
159
+ self._active_threads.discard(key)
@@ -0,0 +1 @@
1
+ """Event schemas for runner JSONL streams."""
@@ -0,0 +1,238 @@
1
+ """Msgspec models and decoder for Claude Code stream-json output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ import msgspec
8
+
9
+
10
+ class StreamTextBlock(
11
+ msgspec.Struct, tag="text", tag_field="type", forbid_unknown_fields=False
12
+ ):
13
+ text: str
14
+
15
+
16
+ class StreamThinkingBlock(
17
+ msgspec.Struct, tag="thinking", tag_field="type", forbid_unknown_fields=False
18
+ ):
19
+ thinking: str
20
+ signature: str
21
+
22
+
23
+ class StreamToolUseBlock(
24
+ msgspec.Struct, tag="tool_use", tag_field="type", forbid_unknown_fields=False
25
+ ):
26
+ id: str
27
+ name: str
28
+ input: dict[str, Any]
29
+
30
+
31
+ class StreamToolResultBlock(
32
+ msgspec.Struct, tag="tool_result", tag_field="type", forbid_unknown_fields=False
33
+ ):
34
+ tool_use_id: str
35
+ content: str | list[dict[str, Any]] | None = None
36
+ is_error: bool | None = None
37
+
38
+
39
+ type StreamContentBlock = (
40
+ StreamTextBlock | StreamThinkingBlock | StreamToolUseBlock | StreamToolResultBlock
41
+ )
42
+
43
+
44
+ class StreamUserMessageBody(msgspec.Struct, forbid_unknown_fields=False):
45
+ role: Literal["user"]
46
+ content: str | list[StreamContentBlock]
47
+
48
+
49
+ class StreamAssistantMessageBody(msgspec.Struct, forbid_unknown_fields=False):
50
+ role: Literal["assistant"]
51
+ content: list[StreamContentBlock]
52
+ model: str
53
+ error: str | None = None
54
+
55
+
56
+ class StreamUserMessage(
57
+ msgspec.Struct, tag="user", tag_field="type", forbid_unknown_fields=False
58
+ ):
59
+ message: StreamUserMessageBody
60
+ uuid: str | None = None
61
+ parent_tool_use_id: str | None = None
62
+ session_id: str | None = None
63
+
64
+
65
+ class StreamAssistantMessage(
66
+ msgspec.Struct, tag="assistant", tag_field="type", forbid_unknown_fields=False
67
+ ):
68
+ message: StreamAssistantMessageBody
69
+ parent_tool_use_id: str | None = None
70
+ uuid: str | None = None
71
+ session_id: str | None = None
72
+
73
+
74
+ class StreamSystemMessage(
75
+ msgspec.Struct, tag="system", tag_field="type", forbid_unknown_fields=False
76
+ ):
77
+ subtype: str
78
+ session_id: str | None = None
79
+ uuid: str | None = None
80
+ cwd: str | None = None
81
+ tools: list[str] | None = None
82
+ mcp_servers: list[Any] | None = None
83
+ model: str | None = None
84
+ permissionMode: str | None = None
85
+ output_style: str | None = None
86
+ apiKeySource: str | None = None
87
+
88
+
89
+ class StreamResultMessage(
90
+ msgspec.Struct, tag="result", tag_field="type", forbid_unknown_fields=False
91
+ ):
92
+ subtype: str
93
+ duration_ms: int
94
+ duration_api_ms: int
95
+ is_error: bool
96
+ num_turns: int
97
+ session_id: str
98
+ total_cost_usd: float | None = None
99
+ usage: dict[str, Any] | None = None
100
+ result: str | None = None
101
+ structured_output: Any = None
102
+
103
+
104
+ class StreamEventMessage(
105
+ msgspec.Struct, tag="stream_event", tag_field="type", forbid_unknown_fields=False
106
+ ):
107
+ uuid: str
108
+ session_id: str
109
+ event: dict[str, Any]
110
+ parent_tool_use_id: str | None = None
111
+
112
+
113
+ class ControlInterruptRequest(
114
+ msgspec.Struct, tag="interrupt", tag_field="subtype", forbid_unknown_fields=False
115
+ ):
116
+ pass
117
+
118
+
119
+ class ControlCanUseToolRequest(
120
+ msgspec.Struct, tag="can_use_tool", tag_field="subtype", forbid_unknown_fields=False
121
+ ):
122
+ tool_name: str
123
+ input: dict[str, Any]
124
+ permission_suggestions: list[Any] | None = None
125
+ blocked_path: str | None = None
126
+
127
+
128
+ class ControlInitializeRequest(
129
+ msgspec.Struct, tag="initialize", tag_field="subtype", forbid_unknown_fields=False
130
+ ):
131
+ hooks: dict[str, Any] | None = None
132
+
133
+
134
+ class ControlSetPermissionModeRequest(
135
+ msgspec.Struct,
136
+ tag="set_permission_mode",
137
+ tag_field="subtype",
138
+ forbid_unknown_fields=False,
139
+ ):
140
+ mode: str
141
+
142
+
143
+ class ControlHookCallbackRequest(
144
+ msgspec.Struct,
145
+ tag="hook_callback",
146
+ tag_field="subtype",
147
+ forbid_unknown_fields=False,
148
+ ):
149
+ callback_id: str
150
+ input: Any
151
+ tool_use_id: str | None = None
152
+
153
+
154
+ class ControlMcpMessageRequest(
155
+ msgspec.Struct, tag="mcp_message", tag_field="subtype", forbid_unknown_fields=False
156
+ ):
157
+ server_name: str
158
+ message: Any
159
+
160
+
161
+ class ControlRewindFilesRequest(
162
+ msgspec.Struct, tag="rewind_files", tag_field="subtype", forbid_unknown_fields=False
163
+ ):
164
+ user_message_id: str
165
+
166
+
167
+ type ControlRequest = (
168
+ ControlInterruptRequest
169
+ | ControlCanUseToolRequest
170
+ | ControlInitializeRequest
171
+ | ControlSetPermissionModeRequest
172
+ | ControlHookCallbackRequest
173
+ | ControlMcpMessageRequest
174
+ | ControlRewindFilesRequest
175
+ )
176
+
177
+
178
+ class StreamControlRequest(
179
+ msgspec.Struct, tag="control_request", tag_field="type", forbid_unknown_fields=False
180
+ ):
181
+ request_id: str
182
+ request: ControlRequest
183
+
184
+
185
+ class ControlSuccessResponse(
186
+ msgspec.Struct, tag="success", tag_field="subtype", forbid_unknown_fields=False
187
+ ):
188
+ request_id: str
189
+ response: dict[str, Any] | None = None
190
+
191
+
192
+ class ControlErrorResponse(
193
+ msgspec.Struct, tag="error", tag_field="subtype", forbid_unknown_fields=False
194
+ ):
195
+ request_id: str
196
+ error: str
197
+
198
+
199
+ type ControlResponse = ControlSuccessResponse | ControlErrorResponse
200
+
201
+
202
+ class StreamControlResponse(
203
+ msgspec.Struct,
204
+ tag="control_response",
205
+ tag_field="type",
206
+ forbid_unknown_fields=False,
207
+ ):
208
+ response: ControlResponse
209
+
210
+
211
+ class StreamControlCancelRequest(
212
+ msgspec.Struct,
213
+ tag="control_cancel_request",
214
+ tag_field="type",
215
+ forbid_unknown_fields=False,
216
+ ):
217
+ request_id: str | None = None
218
+
219
+
220
+ type StreamJsonMessage = (
221
+ StreamUserMessage
222
+ | StreamAssistantMessage
223
+ | StreamSystemMessage
224
+ | StreamResultMessage
225
+ | StreamEventMessage
226
+ | StreamControlRequest
227
+ | StreamControlResponse
228
+ | StreamControlCancelRequest
229
+ )
230
+
231
+
232
+ STREAM_JSON_SCHEMA = msgspec.json.schema(StreamJsonMessage)
233
+
234
+ _DECODER = msgspec.json.Decoder(StreamJsonMessage)
235
+
236
+
237
+ def decode_stream_json_line(line: str | bytes) -> StreamJsonMessage:
238
+ return _DECODER.decode(line)