yee88 0.1.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. takopi/__init__.py +1 -0
  2. takopi/api.py +116 -0
  3. takopi/backends.py +25 -0
  4. takopi/backends_helpers.py +14 -0
  5. takopi/cli/__init__.py +228 -0
  6. takopi/cli/config.py +320 -0
  7. takopi/cli/doctor.py +173 -0
  8. takopi/cli/init.py +113 -0
  9. takopi/cli/onboarding_cmd.py +126 -0
  10. takopi/cli/plugins.py +196 -0
  11. takopi/cli/run.py +419 -0
  12. takopi/cli/topic.py +355 -0
  13. takopi/commands.py +134 -0
  14. takopi/config.py +142 -0
  15. takopi/config_migrations.py +124 -0
  16. takopi/config_watch.py +146 -0
  17. takopi/context.py +9 -0
  18. takopi/directives.py +146 -0
  19. takopi/engines.py +53 -0
  20. takopi/events.py +170 -0
  21. takopi/ids.py +17 -0
  22. takopi/lockfile.py +158 -0
  23. takopi/logging.py +283 -0
  24. takopi/markdown.py +298 -0
  25. takopi/model.py +77 -0
  26. takopi/plugins.py +312 -0
  27. takopi/presenter.py +25 -0
  28. takopi/progress.py +99 -0
  29. takopi/router.py +113 -0
  30. takopi/runner.py +712 -0
  31. takopi/runner_bridge.py +619 -0
  32. takopi/runners/__init__.py +1 -0
  33. takopi/runners/claude.py +483 -0
  34. takopi/runners/codex.py +656 -0
  35. takopi/runners/mock.py +221 -0
  36. takopi/runners/opencode.py +505 -0
  37. takopi/runners/pi.py +523 -0
  38. takopi/runners/run_options.py +39 -0
  39. takopi/runners/tool_actions.py +90 -0
  40. takopi/runtime_loader.py +207 -0
  41. takopi/scheduler.py +159 -0
  42. takopi/schemas/__init__.py +1 -0
  43. takopi/schemas/claude.py +238 -0
  44. takopi/schemas/codex.py +169 -0
  45. takopi/schemas/opencode.py +51 -0
  46. takopi/schemas/pi.py +117 -0
  47. takopi/settings.py +360 -0
  48. takopi/telegram/__init__.py +20 -0
  49. takopi/telegram/api_models.py +37 -0
  50. takopi/telegram/api_schemas.py +152 -0
  51. takopi/telegram/backend.py +163 -0
  52. takopi/telegram/bridge.py +425 -0
  53. takopi/telegram/chat_prefs.py +242 -0
  54. takopi/telegram/chat_sessions.py +112 -0
  55. takopi/telegram/client.py +409 -0
  56. takopi/telegram/client_api.py +539 -0
  57. takopi/telegram/commands/__init__.py +12 -0
  58. takopi/telegram/commands/agent.py +196 -0
  59. takopi/telegram/commands/cancel.py +116 -0
  60. takopi/telegram/commands/dispatch.py +111 -0
  61. takopi/telegram/commands/executor.py +449 -0
  62. takopi/telegram/commands/file_transfer.py +586 -0
  63. takopi/telegram/commands/handlers.py +45 -0
  64. takopi/telegram/commands/media.py +143 -0
  65. takopi/telegram/commands/menu.py +139 -0
  66. takopi/telegram/commands/model.py +215 -0
  67. takopi/telegram/commands/overrides.py +159 -0
  68. takopi/telegram/commands/parse.py +30 -0
  69. takopi/telegram/commands/plan.py +16 -0
  70. takopi/telegram/commands/reasoning.py +234 -0
  71. takopi/telegram/commands/reply.py +23 -0
  72. takopi/telegram/commands/topics.py +332 -0
  73. takopi/telegram/commands/trigger.py +143 -0
  74. takopi/telegram/context.py +140 -0
  75. takopi/telegram/engine_defaults.py +86 -0
  76. takopi/telegram/engine_overrides.py +105 -0
  77. takopi/telegram/files.py +178 -0
  78. takopi/telegram/loop.py +1822 -0
  79. takopi/telegram/onboarding.py +1088 -0
  80. takopi/telegram/outbox.py +177 -0
  81. takopi/telegram/parsing.py +239 -0
  82. takopi/telegram/render.py +198 -0
  83. takopi/telegram/state_store.py +88 -0
  84. takopi/telegram/topic_state.py +334 -0
  85. takopi/telegram/topics.py +256 -0
  86. takopi/telegram/trigger_mode.py +68 -0
  87. takopi/telegram/types.py +63 -0
  88. takopi/telegram/voice.py +110 -0
  89. takopi/transport.py +53 -0
  90. takopi/transport_runtime.py +323 -0
  91. takopi/transports.py +76 -0
  92. takopi/utils/__init__.py +1 -0
  93. takopi/utils/git.py +87 -0
  94. takopi/utils/json_state.py +21 -0
  95. takopi/utils/paths.py +47 -0
  96. takopi/utils/streams.py +44 -0
  97. takopi/utils/subprocess.py +86 -0
  98. takopi/worktrees.py +135 -0
  99. yee88-0.1.0.dist-info/METADATA +116 -0
  100. yee88-0.1.0.dist-info/RECORD +103 -0
  101. yee88-0.1.0.dist-info/WHEEL +4 -0
  102. yee88-0.1.0.dist-info/entry_points.txt +11 -0
  103. yee88-0.1.0.dist-info/licenses/LICENSE +21 -0
takopi/runner.py ADDED
@@ -0,0 +1,712 @@
1
+ """Runner protocol and shared runner definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import subprocess
8
+ from collections.abc import AsyncIterator, Callable
9
+ from dataclasses import dataclass
10
+ from typing import Any, Protocol, cast
11
+ from weakref import WeakValueDictionary
12
+
13
+ import anyio
14
+
15
+ from .logging import get_logger, log_pipeline
16
+ from .model import (
17
+ Action,
18
+ ActionEvent,
19
+ CompletedEvent,
20
+ EngineId,
21
+ ResumeToken,
22
+ StartedEvent,
23
+ TakopiEvent,
24
+ )
25
+ from .utils.paths import get_run_base_dir
26
+ from .utils.streams import drain_stderr, iter_bytes_lines
27
+ from .utils.subprocess import manage_subprocess
28
+
29
+
30
+ class ResumeTokenMixin:
31
+ engine: EngineId
32
+ resume_re: re.Pattern[str]
33
+
34
+ def format_resume(self, token: ResumeToken) -> str:
35
+ if token.engine != self.engine:
36
+ raise RuntimeError(f"resume token is for engine {token.engine!r}")
37
+ return f"`{self.engine} resume {token.value}`"
38
+
39
+ def is_resume_line(self, line: str) -> bool:
40
+ return bool(self.resume_re.match(line))
41
+
42
+ def extract_resume(self, text: str | None) -> ResumeToken | None:
43
+ if not text:
44
+ return None
45
+ found: str | None = None
46
+ for match in self.resume_re.finditer(text):
47
+ token = match.group("token")
48
+ if token:
49
+ found = token
50
+ if not found:
51
+ return None
52
+ return ResumeToken(engine=self.engine, value=found)
53
+
54
+
55
+ class SessionLockMixin:
56
+ engine: EngineId
57
+ session_locks: WeakValueDictionary[str, anyio.Semaphore] | None = None
58
+
59
+ def lock_for(self, token: ResumeToken) -> anyio.Semaphore:
60
+ locks = self.session_locks
61
+ if locks is None:
62
+ locks = WeakValueDictionary()
63
+ self.session_locks = locks
64
+ key = f"{token.engine}:{token.value}"
65
+ lock = locks.get(key)
66
+ if lock is None:
67
+ lock = anyio.Semaphore(1)
68
+ locks[key] = lock
69
+ return lock
70
+
71
+ async def run_with_resume_lock(
72
+ self,
73
+ prompt: str,
74
+ resume: ResumeToken | None,
75
+ run_fn: Callable[[str, ResumeToken | None], AsyncIterator[TakopiEvent]],
76
+ ) -> AsyncIterator[TakopiEvent]:
77
+ resume_token = resume
78
+ if resume_token is not None and resume_token.engine != self.engine:
79
+ raise RuntimeError(
80
+ f"resume token is for engine {resume_token.engine!r}, not {self.engine!r}"
81
+ )
82
+ if resume_token is None:
83
+ async for evt in run_fn(prompt, resume_token):
84
+ yield evt
85
+ return
86
+ lock = self.lock_for(resume_token)
87
+ async with lock:
88
+ async for evt in run_fn(prompt, resume_token):
89
+ yield evt
90
+
91
+
92
+ class BaseRunner(SessionLockMixin):
93
+ engine: EngineId
94
+
95
+ def run(
96
+ self, prompt: str, resume: ResumeToken | None
97
+ ) -> AsyncIterator[TakopiEvent]:
98
+ return self.run_locked(prompt, resume)
99
+
100
+ async def run_locked(
101
+ self, prompt: str, resume: ResumeToken | None
102
+ ) -> AsyncIterator[TakopiEvent]:
103
+ if resume is not None:
104
+ async for evt in self.run_with_resume_lock(prompt, resume, self.run_impl):
105
+ yield evt
106
+ return
107
+
108
+ lock: anyio.Semaphore | None = None
109
+ acquired = False
110
+ try:
111
+ async for evt in self.run_impl(prompt, None):
112
+ if lock is None and isinstance(evt, StartedEvent):
113
+ lock = self.lock_for(evt.resume)
114
+ await lock.acquire()
115
+ acquired = True
116
+ yield evt
117
+ finally:
118
+ if acquired and lock is not None:
119
+ lock.release()
120
+
121
+ async def run_impl(
122
+ self, prompt: str, resume: ResumeToken | None
123
+ ) -> AsyncIterator[TakopiEvent]:
124
+ if False:
125
+ yield # pragma: no cover
126
+ raise NotImplementedError
127
+
128
+
129
+ @dataclass(slots=True)
130
+ class JsonlRunState:
131
+ note_seq: int = 0
132
+
133
+
134
+ @dataclass(slots=True)
135
+ class JsonlStreamState:
136
+ expected_session: ResumeToken | None
137
+ found_session: ResumeToken | None = None
138
+ did_emit_completed: bool = False
139
+ ignored_after_completed: bool = False
140
+ jsonl_seq: int = 0
141
+
142
+
143
+ class JsonlSubprocessRunner(BaseRunner):
144
+ def get_logger(self) -> Any:
145
+ return getattr(self, "logger", get_logger(__name__))
146
+
147
+ def command(self) -> str:
148
+ raise NotImplementedError
149
+
150
+ def tag(self) -> str:
151
+ return str(self.engine)
152
+
153
+ def build_args(
154
+ self,
155
+ prompt: str,
156
+ resume: ResumeToken | None,
157
+ *,
158
+ state: Any,
159
+ ) -> list[str]:
160
+ raise NotImplementedError
161
+
162
+ def stdin_payload(
163
+ self,
164
+ prompt: str,
165
+ resume: ResumeToken | None,
166
+ *,
167
+ state: Any,
168
+ ) -> bytes | None:
169
+ return prompt.encode()
170
+
171
+ def env(self, *, state: Any) -> dict[str, str] | None:
172
+ return None
173
+
174
+ def new_state(self, prompt: str, resume: ResumeToken | None) -> Any:
175
+ return JsonlRunState()
176
+
177
+ def start_run(
178
+ self,
179
+ prompt: str,
180
+ resume: ResumeToken | None,
181
+ *,
182
+ state: Any,
183
+ ) -> None:
184
+ return None
185
+
186
+ def pipes_error_message(self) -> str:
187
+ return f"{self.tag()} failed to open subprocess pipes"
188
+
189
+ def next_note_id(self, state: Any) -> str:
190
+ try:
191
+ note_seq = state.note_seq
192
+ except AttributeError as exc:
193
+ raise RuntimeError(
194
+ "state must define note_seq or override next_note_id"
195
+ ) from exc
196
+ state.note_seq = note_seq + 1
197
+ return f"{self.tag()}.note.{state.note_seq}"
198
+
199
+ def note_event(
200
+ self,
201
+ message: str,
202
+ *,
203
+ state: Any,
204
+ ok: bool = False,
205
+ detail: dict[str, Any] | None = None,
206
+ ) -> TakopiEvent:
207
+ note_id = self.next_note_id(state)
208
+ action = Action(
209
+ id=note_id,
210
+ kind="warning",
211
+ title=message,
212
+ detail=detail or {},
213
+ )
214
+ return ActionEvent(
215
+ engine=self.engine,
216
+ action=action,
217
+ phase="completed",
218
+ ok=ok,
219
+ message=message,
220
+ level="info" if ok else "warning",
221
+ )
222
+
223
+ def invalid_json_events(
224
+ self,
225
+ *,
226
+ raw: str,
227
+ line: str,
228
+ state: Any,
229
+ ) -> list[TakopiEvent]:
230
+ message = f"invalid JSON from {self.tag()}; ignoring line"
231
+ return [self.note_event(message, state=state, detail={"line": line})]
232
+
233
+ def decode_jsonl(self, *, line: bytes) -> Any | None:
234
+ text = line.decode("utf-8", errors="replace")
235
+ try:
236
+ return cast(dict[str, Any], json.loads(text))
237
+ except json.JSONDecodeError:
238
+ return None
239
+
240
+ async def iter_json_lines(
241
+ self,
242
+ stream: Any,
243
+ ) -> AsyncIterator[bytes]:
244
+ async for raw_line in iter_bytes_lines(stream):
245
+ yield raw_line.rstrip(b"\n")
246
+
247
+ def decode_error_events(
248
+ self,
249
+ *,
250
+ raw: str,
251
+ line: str,
252
+ error: Exception,
253
+ state: Any,
254
+ ) -> list[TakopiEvent]:
255
+ message = f"invalid event from {self.tag()}; ignoring line"
256
+ detail = {"line": line, "error": str(error)}
257
+ return [self.note_event(message, state=state, detail=detail)]
258
+
259
+ def translate_error_events(
260
+ self,
261
+ *,
262
+ data: Any,
263
+ error: Exception,
264
+ state: Any,
265
+ ) -> list[TakopiEvent]:
266
+ message = f"{self.tag()} translation error; ignoring event"
267
+ detail: dict[str, Any] = {"error": str(error)}
268
+ if isinstance(data, dict):
269
+ detail["type"] = data.get("type")
270
+ item = data.get("item")
271
+ if isinstance(item, dict):
272
+ detail["item_type"] = item.get("type") or item.get("item_type")
273
+ return [self.note_event(message, state=state, detail=detail)]
274
+
275
+ def process_error_events(
276
+ self,
277
+ rc: int,
278
+ *,
279
+ resume: ResumeToken | None,
280
+ found_session: ResumeToken | None,
281
+ state: Any,
282
+ ) -> list[TakopiEvent]:
283
+ message = f"{self.tag()} failed (rc={rc})."
284
+ resume_for_completed = found_session or resume
285
+ return [
286
+ self.note_event(message, state=state),
287
+ CompletedEvent(
288
+ engine=self.engine,
289
+ ok=False,
290
+ answer="",
291
+ resume=resume_for_completed,
292
+ error=message,
293
+ ),
294
+ ]
295
+
296
+ def stream_end_events(
297
+ self,
298
+ *,
299
+ resume: ResumeToken | None,
300
+ found_session: ResumeToken | None,
301
+ state: Any,
302
+ ) -> list[TakopiEvent]:
303
+ message = f"{self.tag()} finished without a result event"
304
+ resume_for_completed = found_session or resume
305
+ return [
306
+ CompletedEvent(
307
+ engine=self.engine,
308
+ ok=False,
309
+ answer="",
310
+ resume=resume_for_completed,
311
+ error=message,
312
+ )
313
+ ]
314
+
315
+ def translate(
316
+ self,
317
+ data: Any,
318
+ *,
319
+ state: Any,
320
+ resume: ResumeToken | None,
321
+ found_session: ResumeToken | None,
322
+ ) -> list[TakopiEvent]:
323
+ raise NotImplementedError
324
+
325
+ def handle_started_event(
326
+ self,
327
+ event: StartedEvent,
328
+ *,
329
+ expected_session: ResumeToken | None,
330
+ found_session: ResumeToken | None,
331
+ ) -> tuple[ResumeToken | None, bool]:
332
+ if event.engine != self.engine:
333
+ raise RuntimeError(
334
+ f"{self.tag()} emitted session token for engine {event.engine!r}"
335
+ )
336
+ if expected_session is not None and event.resume != expected_session:
337
+ message = (
338
+ f"{self.tag()} emitted session id {event.resume.value} "
339
+ f"but expected {expected_session.value}"
340
+ )
341
+ raise RuntimeError(message)
342
+ if found_session is None:
343
+ return event.resume, True
344
+ if event.resume != found_session:
345
+ message = (
346
+ f"{self.tag()} emitted session id {event.resume.value} "
347
+ f"but expected {found_session.value}"
348
+ )
349
+ raise RuntimeError(message)
350
+ return found_session, False
351
+
352
+ async def _send_payload(
353
+ self,
354
+ proc: Any,
355
+ payload: bytes | None,
356
+ *,
357
+ logger: Any,
358
+ resume: ResumeToken | None,
359
+ ) -> None:
360
+ if payload is not None:
361
+ assert proc.stdin is not None
362
+ await proc.stdin.send(payload)
363
+ await proc.stdin.aclose()
364
+ logger.info(
365
+ "subprocess.stdin.send",
366
+ pid=proc.pid,
367
+ resume=resume.value if resume else None,
368
+ bytes=len(payload),
369
+ )
370
+ elif proc.stdin is not None:
371
+ await proc.stdin.aclose()
372
+
373
+ def _decode_jsonl_events(
374
+ self,
375
+ *,
376
+ raw_line: bytes,
377
+ line: bytes,
378
+ jsonl_seq: int,
379
+ state: Any,
380
+ resume: ResumeToken | None,
381
+ found_session: ResumeToken | None,
382
+ logger: Any,
383
+ pid: int,
384
+ ) -> list[TakopiEvent]:
385
+ raw_text = raw_line.decode("utf-8", errors="replace")
386
+ line_text = line.decode("utf-8", errors="replace")
387
+ try:
388
+ decoded = self.decode_jsonl(line=line)
389
+ except Exception as exc: # noqa: BLE001
390
+ log_pipeline(
391
+ logger,
392
+ "jsonl.parse.error",
393
+ pid=pid,
394
+ jsonl_seq=jsonl_seq,
395
+ line=line_text,
396
+ error=str(exc),
397
+ )
398
+ return self.decode_error_events(
399
+ raw=raw_text,
400
+ line=line_text,
401
+ error=exc,
402
+ state=state,
403
+ )
404
+ if decoded is None:
405
+ log_pipeline(
406
+ logger,
407
+ "jsonl.parse.invalid",
408
+ pid=pid,
409
+ jsonl_seq=jsonl_seq,
410
+ line=line_text,
411
+ )
412
+ logger.info(
413
+ "runner.jsonl.invalid",
414
+ pid=pid,
415
+ jsonl_seq=jsonl_seq,
416
+ line=line_text,
417
+ )
418
+ return self.invalid_json_events(
419
+ raw=raw_text,
420
+ line=line_text,
421
+ state=state,
422
+ )
423
+ try:
424
+ return self.translate(
425
+ decoded,
426
+ state=state,
427
+ resume=resume,
428
+ found_session=found_session,
429
+ )
430
+ except Exception as exc: # noqa: BLE001
431
+ log_pipeline(
432
+ logger,
433
+ "runner.translate.error",
434
+ pid=pid,
435
+ jsonl_seq=jsonl_seq,
436
+ error=str(exc),
437
+ )
438
+ return self.translate_error_events(
439
+ data=decoded,
440
+ error=exc,
441
+ state=state,
442
+ )
443
+
444
+ def _process_started_event(
445
+ self,
446
+ event: StartedEvent,
447
+ *,
448
+ expected_session: ResumeToken | None,
449
+ found_session: ResumeToken | None,
450
+ logger: Any,
451
+ pid: int,
452
+ jsonl_seq: int,
453
+ ) -> tuple[ResumeToken | None, bool]:
454
+ prior_found = found_session
455
+ try:
456
+ found_session, emit = self.handle_started_event(
457
+ event,
458
+ expected_session=expected_session,
459
+ found_session=found_session,
460
+ )
461
+ except Exception as exc:
462
+ log_pipeline(
463
+ logger,
464
+ "runner.started.error",
465
+ pid=pid,
466
+ jsonl_seq=jsonl_seq,
467
+ resume=event.resume.value,
468
+ expected_session=expected_session.value if expected_session else None,
469
+ found_session=prior_found.value if prior_found else None,
470
+ error=str(exc),
471
+ )
472
+ raise
473
+ if prior_found is None and emit:
474
+ reason = (
475
+ "matched_expected" if expected_session is not None else "first_seen"
476
+ )
477
+ elif prior_found is not None and not emit:
478
+ reason = "duplicate"
479
+ else:
480
+ reason = "unknown"
481
+ log_pipeline(
482
+ logger,
483
+ "runner.started.seen",
484
+ pid=pid,
485
+ jsonl_seq=jsonl_seq,
486
+ resume=event.resume.value,
487
+ expected_session=expected_session.value if expected_session else None,
488
+ found_session=found_session.value if found_session else None,
489
+ emit=emit,
490
+ reason=reason,
491
+ )
492
+ return found_session, emit
493
+
494
+ def _log_completed_event(
495
+ self,
496
+ *,
497
+ logger: Any,
498
+ pid: int,
499
+ event: CompletedEvent,
500
+ jsonl_seq: int | None = None,
501
+ source: str | None = None,
502
+ ) -> None:
503
+ payload: dict[str, Any] = {
504
+ "pid": pid,
505
+ "ok": event.ok,
506
+ "has_answer": bool(event.answer.strip()),
507
+ "emit": True,
508
+ }
509
+ if jsonl_seq is not None:
510
+ payload["jsonl_seq"] = jsonl_seq
511
+ if source is not None:
512
+ payload["source"] = source
513
+ log_pipeline(logger, "runner.completed.seen", **payload)
514
+
515
+ def _handle_jsonl_line(
516
+ self,
517
+ *,
518
+ raw_line: bytes,
519
+ stream: JsonlStreamState,
520
+ state: Any,
521
+ resume: ResumeToken | None,
522
+ logger: Any,
523
+ pid: int,
524
+ ) -> list[TakopiEvent]:
525
+ if stream.did_emit_completed:
526
+ if not stream.ignored_after_completed:
527
+ log_pipeline(
528
+ logger,
529
+ "runner.drop.jsonl_after_completed",
530
+ pid=pid,
531
+ )
532
+ stream.ignored_after_completed = True
533
+ return []
534
+ line = raw_line.strip()
535
+ if not line:
536
+ return []
537
+ stream.jsonl_seq += 1
538
+ seq = stream.jsonl_seq
539
+ events = self._decode_jsonl_events(
540
+ raw_line=raw_line,
541
+ line=line,
542
+ jsonl_seq=seq,
543
+ state=state,
544
+ resume=resume,
545
+ found_session=stream.found_session,
546
+ logger=logger,
547
+ pid=pid,
548
+ )
549
+ output: list[TakopiEvent] = []
550
+ for evt in events:
551
+ if isinstance(evt, StartedEvent):
552
+ stream.found_session, emit = self._process_started_event(
553
+ evt,
554
+ expected_session=stream.expected_session,
555
+ found_session=stream.found_session,
556
+ logger=logger,
557
+ pid=pid,
558
+ jsonl_seq=seq,
559
+ )
560
+ if not emit:
561
+ continue
562
+ if isinstance(evt, CompletedEvent):
563
+ stream.did_emit_completed = True
564
+ self._log_completed_event(
565
+ logger=logger,
566
+ pid=pid,
567
+ event=evt,
568
+ jsonl_seq=seq,
569
+ )
570
+ output.append(evt)
571
+ break
572
+ output.append(evt)
573
+ return output
574
+
575
+ async def _iter_jsonl_events(
576
+ self,
577
+ *,
578
+ stdout: Any,
579
+ stream: JsonlStreamState,
580
+ state: Any,
581
+ resume: ResumeToken | None,
582
+ logger: Any,
583
+ pid: int,
584
+ ) -> AsyncIterator[TakopiEvent]:
585
+ async for raw_line in self.iter_json_lines(stdout):
586
+ for evt in self._handle_jsonl_line(
587
+ raw_line=raw_line,
588
+ stream=stream,
589
+ state=state,
590
+ resume=resume,
591
+ logger=logger,
592
+ pid=pid,
593
+ ):
594
+ yield evt
595
+
596
+ async def run_impl(
597
+ self, prompt: str, resume: ResumeToken | None
598
+ ) -> AsyncIterator[TakopiEvent]:
599
+ state = self.new_state(prompt, resume)
600
+ self.start_run(prompt, resume, state=state)
601
+
602
+ tag = self.tag()
603
+ logger = self.get_logger()
604
+ cmd = [self.command(), *self.build_args(prompt, resume, state=state)]
605
+ payload = self.stdin_payload(prompt, resume, state=state)
606
+ env = self.env(state=state)
607
+ logger.info(
608
+ "runner.start",
609
+ engine=self.engine,
610
+ resume=resume.value if resume else None,
611
+ prompt=prompt,
612
+ prompt_len=len(prompt),
613
+ )
614
+
615
+ cwd = get_run_base_dir()
616
+
617
+ async with manage_subprocess(
618
+ cmd,
619
+ stdin=subprocess.PIPE,
620
+ stdout=subprocess.PIPE,
621
+ stderr=subprocess.PIPE,
622
+ env=env,
623
+ cwd=cwd,
624
+ ) as proc:
625
+ if proc.stdout is None or proc.stderr is None:
626
+ raise RuntimeError(self.pipes_error_message())
627
+ if payload is not None and proc.stdin is None:
628
+ raise RuntimeError(self.pipes_error_message())
629
+
630
+ logger.info(
631
+ "subprocess.spawn",
632
+ cmd=cmd[0] if cmd else None,
633
+ args=cmd[1:],
634
+ pid=proc.pid,
635
+ )
636
+
637
+ await self._send_payload(proc, payload, logger=logger, resume=resume)
638
+
639
+ rc: int | None = None
640
+ stream = JsonlStreamState(expected_session=resume)
641
+
642
+ async with anyio.create_task_group() as tg:
643
+ tg.start_soon(
644
+ drain_stderr,
645
+ proc.stderr,
646
+ logger,
647
+ tag,
648
+ )
649
+ async for evt in self._iter_jsonl_events(
650
+ stdout=proc.stdout,
651
+ stream=stream,
652
+ state=state,
653
+ resume=resume,
654
+ logger=logger,
655
+ pid=proc.pid,
656
+ ):
657
+ yield evt
658
+
659
+ rc = await proc.wait()
660
+
661
+ logger.info("subprocess.exit", pid=proc.pid, rc=rc)
662
+ if stream.did_emit_completed:
663
+ return
664
+ found_session = stream.found_session
665
+ if rc is not None and rc != 0:
666
+ events = self.process_error_events(
667
+ rc,
668
+ resume=resume,
669
+ found_session=found_session,
670
+ state=state,
671
+ )
672
+ for evt in events:
673
+ if isinstance(evt, CompletedEvent):
674
+ self._log_completed_event(
675
+ logger=logger,
676
+ pid=proc.pid,
677
+ event=evt,
678
+ source="process_error",
679
+ )
680
+ yield evt
681
+ return
682
+
683
+ events = self.stream_end_events(
684
+ resume=resume,
685
+ found_session=found_session,
686
+ state=state,
687
+ )
688
+ for evt in events:
689
+ if isinstance(evt, CompletedEvent):
690
+ self._log_completed_event(
691
+ logger=logger,
692
+ pid=proc.pid,
693
+ event=evt,
694
+ source="stream_end",
695
+ )
696
+ yield evt
697
+
698
+
699
+ class Runner(Protocol):
700
+ engine: str
701
+
702
+ def is_resume_line(self, line: str) -> bool: ...
703
+
704
+ def format_resume(self, token: ResumeToken) -> str: ...
705
+
706
+ def extract_resume(self, text: str | None) -> ResumeToken | None: ...
707
+
708
+ def run(
709
+ self,
710
+ prompt: str,
711
+ resume: ResumeToken | None,
712
+ ) -> AsyncIterator[TakopiEvent]: ...