codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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 (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -17,6 +17,26 @@ ABOUT_CAR_REL_PATH = Path(".codex-autorunner") / ABOUT_CAR_BASENAME
17
17
  # If this marker is present, codex-autorunner may safely refresh the file content.
18
18
  ABOUT_CAR_GENERATED_MARKER = "<!-- CAR:AUTOGENERATED -->"
19
19
 
20
+ CAR_CONTEXT_KEYWORDS = (
21
+ "car",
22
+ "codex",
23
+ "spec",
24
+ "autorunner",
25
+ "workspace",
26
+ "ticket",
27
+ "tickets",
28
+ "context",
29
+ "decision",
30
+ "decisions",
31
+ "handoff",
32
+ "dispatch",
33
+ "inbox",
34
+ )
35
+
36
+ CAR_CONTEXT_HINT = (
37
+ "Context: read .codex-autorunner/ABOUT_CAR.md for repo-specific rules."
38
+ )
39
+
20
40
 
21
41
  def _display_path(repo_root: Path, path: Path) -> str:
22
42
  try:
@@ -28,11 +48,9 @@ def _display_path(repo_root: Path, path: Path) -> str:
28
48
  def build_about_car_markdown(
29
49
  *,
30
50
  repo_root: Path,
31
- todo_path: Path,
32
- progress_path: Path,
33
- opinions_path: Path,
51
+ active_context_path: Path,
52
+ decisions_path: Path,
34
53
  spec_path: Path,
35
- summary_path: Path,
36
54
  hub_config_path: Optional[Path] = None,
37
55
  repo_override_path: Optional[Path] = None,
38
56
  ) -> str:
@@ -44,11 +62,9 @@ def build_about_car_markdown(
44
62
  repo_override_path = repo_override_path or (repo_root / REPO_OVERRIDE_FILENAME)
45
63
  root_config_path = repo_root / ROOT_CONFIG_FILENAME
46
64
  root_override_path = repo_root / ROOT_OVERRIDE_FILENAME
47
- todo_disp = _display_path(repo_root, todo_path)
48
- progress_disp = _display_path(repo_root, progress_path)
49
- opinions_disp = _display_path(repo_root, opinions_path)
65
+ active_context_disp = _display_path(repo_root, active_context_path)
66
+ decisions_disp = _display_path(repo_root, decisions_path)
50
67
  spec_disp = _display_path(repo_root, spec_path)
51
- summary_disp = _display_path(repo_root, summary_path)
52
68
  hub_config_disp = _display_path(repo_root, hub_config_path)
53
69
  repo_override_disp = _display_path(repo_root, repo_override_path)
54
70
  root_config_disp = _display_path(repo_root, root_config_path)
@@ -58,25 +74,25 @@ def build_about_car_markdown(
58
74
  f"{ABOUT_CAR_GENERATED_MARKER}\n"
59
75
  "# ABOUT_CAR — Codex Autorunner (CAR)\n\n"
60
76
  "You are running inside **Codex Autorunner (CAR)**.\n\n"
61
- "CAR uses a small set of markdown **work docs** as the control surface for long-horizon work. "
62
- "These docs live under the repo-local, gitignored `.codex-autorunner/` directory.\n\n"
63
- "## Work docs (canonical)\n"
64
- "- **TODO** ordered checklist of high-level tasks: "
65
- f"`{todo_disp}`\n"
66
- "- **PROGRESS** — running notes / validation / context: "
67
- f"`{progress_disp}`\n"
68
- "- **OPINIONS** — constraints + style guidelines: "
69
- f"`{opinions_disp}`\n"
70
- "- **SPEC** — source-of-truth requirements and scope: "
71
- f"`{spec_disp}`\n"
72
- "- **SUMMARY** — user-facing report + external/user action items: "
73
- f"`{summary_disp}`\n\n"
77
+ "CAR uses a ticket-first workflow.\n\n"
78
+ "## Required for operation\n"
79
+ "- Tickets live under `.codex-autorunner/tickets/`.\n\n"
80
+ "## Optional workspace docs\n"
81
+ "- **Active context**: "
82
+ f"`{active_context_disp}`\n"
83
+ "- **Decisions**: "
84
+ f"`{decisions_disp}`\n"
85
+ "- **Spec**: "
86
+ f"`{spec_disp}`\n\n"
74
87
  "## Critical rules\n"
75
- f'- When the user says **"add this to the TODOs"**, edit `{todo_disp}`.\n'
76
- "- Do **not** create new copies of TODO/PROGRESS/OPINIONS/SPEC/SUMMARY elsewhere in the repo.\n"
88
+ "- Do **not** create new copies of workspace docs elsewhere in the repo.\n"
77
89
  "- Treat `.codex-autorunner/` as intentional project structure even though it is hidden/gitignored.\n\n"
90
+ "## Agent Flow\n"
91
+ "- **Dispatch**: An update or message from the agent.\n"
92
+ "- **Handoff**: Passing control from agent to user (or vice versa).\n"
93
+ "- **Inbox**: Where the agent receives files/messages.\n\n"
78
94
  "## How CAR works (short)\n"
79
- "- `car run/once` repeatedly runs Codex non-interactively, feeding it the work docs (and the prior run tail).\n"
95
+ "- The web UI provides ticket editing + unified file chat.\n"
80
96
  "- `car serve` starts the hub web UI. The **Terminal** tab launches the configured `codex` binary in a PTY.\n"
81
97
  f"- Hub config lives at `{hub_config_disp}` (generated).\n"
82
98
  f"- Repo overrides (optional) live at `{repo_override_disp}`.\n"
@@ -101,11 +117,9 @@ def ensure_about_car_file_for_repo(
101
117
 
102
118
  content = build_about_car_markdown(
103
119
  repo_root=repo_root,
104
- todo_path=doc_paths["todo"],
105
- progress_path=doc_paths["progress"],
106
- opinions_path=doc_paths["opinions"],
120
+ active_context_path=doc_paths["active_context"],
121
+ decisions_path=doc_paths["decisions"],
107
122
  spec_path=doc_paths["spec"],
108
- summary_path=doc_paths["summary"],
109
123
  )
110
124
  if content and not content.endswith("\n"):
111
125
  content += "\n"
@@ -129,10 +143,8 @@ def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
129
143
  """Config-aware wrapper that uses configured doc paths."""
130
144
  repo_root = config.root
131
145
  docs = {
132
- "todo": config.doc_path("todo"),
133
- "progress": config.doc_path("progress"),
134
- "opinions": config.doc_path("opinions"),
146
+ "active_context": config.doc_path("active_context"),
147
+ "decisions": config.doc_path("decisions"),
135
148
  "spec": config.doc_path("spec"),
136
- "summary": config.doc_path("summary"),
137
149
  }
138
150
  return ensure_about_car_file_for_repo(repo_root, doc_paths=docs, force=force)
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Callable
4
+
5
+
6
+ def handle_agent_output(
7
+ log_app_server_output: Callable[[int, list[str]], None],
8
+ write_run_artifact: Callable[[int, str, str], Any],
9
+ merge_run_index_entry: Callable[[int, dict[str, Any]], None],
10
+ run_id: int,
11
+ output: str | list[str],
12
+ ) -> None:
13
+ if isinstance(output, str):
14
+ messages = [output]
15
+ else:
16
+ messages = output
17
+ log_app_server_output(run_id, messages)
18
+ output_text = "\n\n".join(messages).strip() if messages else ""
19
+ if output_text:
20
+ output_path = write_run_artifact(run_id, "output.txt", output_text)
21
+ merge_run_index_entry(run_id, {"artifacts": {"output_path": str(output_path)}})
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import json
3
+ import threading
3
4
  import time
4
5
  from dataclasses import dataclass, field
5
6
  from typing import Any, AsyncIterator, Dict, Optional
@@ -45,11 +46,19 @@ class AppServerEventBuffer:
45
46
  ) -> None:
46
47
  self._entries: dict[TurnKey, TurnEventEntry] = {}
47
48
  self._turn_index: dict[str, str] = {}
48
- self._lock = asyncio.Lock()
49
+ self._lock: Optional[asyncio.Lock] = None
50
+ self._lock_init = threading.Lock()
49
51
  self._max_events_per_turn = max_events_per_turn
50
52
  self._max_turns = max_turns
51
53
  self._turn_ttl_seconds = turn_ttl_seconds
52
54
 
55
+ def _ensure_lock(self) -> asyncio.Lock:
56
+ if self._lock is None:
57
+ with self._lock_init:
58
+ if self._lock is None:
59
+ self._lock = asyncio.Lock()
60
+ return self._lock
61
+
53
62
  async def register_turn(
54
63
  self,
55
64
  thread_id: str,
@@ -60,7 +69,7 @@ class AppServerEventBuffer:
60
69
  if not thread_id or not turn_id:
61
70
  return
62
71
  entry = await self._ensure_entry(thread_id, turn_id)
63
- async with self._lock:
72
+ async with self._ensure_lock():
64
73
  self._turn_index[turn_id] = thread_id
65
74
  if context:
66
75
  entry.context.update(context)
@@ -83,7 +92,7 @@ class AppServerEventBuffer:
83
92
  entry.last_event_at = time.monotonic()
84
93
  entry.condition.notify_all()
85
94
  context = dict(entry.context) if entry.context else {}
86
- async with self._lock:
95
+ async with self._ensure_lock():
87
96
  self._turn_index[turn_id] = thread_id
88
97
  self._prune_locked()
89
98
  self._emit_log_lines(context, message)
@@ -96,7 +105,7 @@ class AppServerEventBuffer:
96
105
  heartbeat_interval: float = 15.0,
97
106
  ) -> AsyncIterator[str]:
98
107
  entry = await self._ensure_entry(thread_id, turn_id)
99
- async with self._lock:
108
+ async with self._ensure_lock():
100
109
  entry.active_streams += 1
101
110
  self._turn_index[turn_id] = thread_id
102
111
  last_id = 0
@@ -116,12 +125,12 @@ class AppServerEventBuffer:
116
125
  last_id = event["id"]
117
126
  yield format_sse("app-server", event)
118
127
  finally:
119
- async with self._lock:
128
+ async with self._ensure_lock():
120
129
  entry.active_streams = max(0, entry.active_streams - 1)
121
130
 
122
131
  async def _ensure_entry(self, thread_id: str, turn_id: str) -> TurnEventEntry:
123
132
  key = (thread_id, turn_id)
124
- async with self._lock:
133
+ async with self._ensure_lock():
125
134
  entry = self._entries.get(key)
126
135
  if entry is None:
127
136
  entry = TurnEventEntry(thread_id=thread_id, turn_id=turn_id)
@@ -1,6 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Any
3
+ from typing import Any, cast
4
+
5
+ from .redaction import redact_text
6
+ from .text_delta_coalescer import TextDeltaCoalescer
4
7
 
5
8
 
6
9
  def _coerce_dict(value: Any) -> dict[str, Any]:
@@ -67,11 +70,14 @@ def _extract_error_message(params: Any) -> str:
67
70
 
68
71
 
69
72
  class AppServerEventFormatter:
70
- def __init__(self) -> None:
73
+ def __init__(self, redact_enabled: bool = True) -> None:
74
+ self._redact_enabled = redact_enabled
71
75
  self._thinking_items: set[str] = set()
76
+ self._reasoning_coalescers: dict[str, TextDeltaCoalescer] = {}
72
77
 
73
78
  def reset(self) -> None:
74
79
  self._thinking_items.clear()
80
+ self._reasoning_coalescers.clear()
75
81
 
76
82
  def format_event(self, message: Any) -> list[str]:
77
83
  if not isinstance(message, dict):
@@ -86,23 +92,41 @@ class AppServerEventFormatter:
86
92
  delta = params.get("delta")
87
93
  if not isinstance(delta, str) or not delta:
88
94
  return []
95
+ has_valid_item_id = isinstance(item_id, str) and item_id
96
+ if has_valid_item_id and item_id not in self._thinking_items:
97
+ lines.append("thinking")
98
+ self._thinking_items.add(cast(str, item_id))
99
+ if has_valid_item_id:
100
+ if item_id not in self._reasoning_coalescers:
101
+ self._reasoning_coalescers[cast(str, item_id)] = (
102
+ TextDeltaCoalescer()
103
+ )
104
+ self._reasoning_coalescers[cast(str, item_id)].add(delta)
105
+ else:
106
+ lines.append("thinking")
107
+ for line in delta.splitlines() or [""]:
108
+ if line:
109
+ lines.append(f"**{line}**")
110
+ else:
111
+ lines.append("")
112
+ return lines
113
+
114
+ if method == "item/reasoning/summaryPartAdded":
89
115
  if (
90
116
  isinstance(item_id, str)
91
117
  and item_id
92
- and item_id not in self._thinking_items
118
+ and item_id in self._reasoning_coalescers
93
119
  ):
94
- lines.append("thinking")
95
- self._thinking_items.add(item_id)
96
- for line in delta.splitlines() or [""]:
97
- if line:
98
- lines.append(f"**{line}**")
99
- else:
100
- lines.append("")
120
+ coalescer = self._reasoning_coalescers[item_id]
121
+ buffer = coalescer.flush_all()
122
+ for line in buffer:
123
+ if line:
124
+ lines.append(f"**{line}**")
125
+ else:
126
+ lines.append("")
127
+ self._reasoning_coalescers[item_id].clear()
101
128
  return lines
102
129
 
103
- if method == "item/reasoning/summaryPartAdded":
104
- return []
105
-
106
130
  if method in ("turn/completed", "error"):
107
131
  self.reset()
108
132
 
@@ -134,6 +158,20 @@ class AppServerEventFormatter:
134
158
  if files:
135
159
  lines.append("file update")
136
160
  lines.extend([f"M {path}" for path in files])
161
+ elif item_type == "reasoning":
162
+ if (
163
+ isinstance(item_id, str)
164
+ and item_id
165
+ and item_id in self._reasoning_coalescers
166
+ ):
167
+ coalescer = self._reasoning_coalescers[item_id]
168
+ buffer = coalescer.flush_all()
169
+ self._reasoning_coalescers.pop(item_id, None)
170
+ for line in buffer:
171
+ if line:
172
+ lines.append(f"**{line}**")
173
+ else:
174
+ lines.append("")
137
175
  elif item_type == "tool":
138
176
  tool_name = item.get("name") or item.get("tool") or item.get("id")
139
177
  if isinstance(tool_name, str) and tool_name:
@@ -151,7 +189,8 @@ class AppServerEventFormatter:
151
189
  or params.get("value")
152
190
  )
153
191
  if isinstance(diff, str) and diff:
154
- lines.extend(diff.splitlines())
192
+ diff_text = redact_text(diff) if self._redact_enabled else diff
193
+ lines.extend(diff_text.splitlines())
155
194
  return lines
156
195
 
157
196
  if method == "error":
@@ -163,7 +202,8 @@ class AppServerEventFormatter:
163
202
  if "outputdelta" in method.lower():
164
203
  delta = params.get("delta") or params.get("text") or params.get("output")
165
204
  if isinstance(delta, str) and delta:
166
- lines.extend(delta.splitlines())
205
+ delta_text = redact_text(delta) if self._redact_enabled else delta
206
+ lines.extend(delta_text.splitlines())
167
207
  return lines
168
208
 
169
209
  return lines