android-midscene-automation 0.2.3 → 1.1.0

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 (525) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +63 -1
  3. package/artemis-main/.dockerignore +15 -0
  4. package/artemis-main/.env.example +34 -0
  5. package/artemis-main/CONTRIBUTING.md +35 -0
  6. package/artemis-main/Dockerfile +68 -0
  7. package/artemis-main/LICENSE +202 -0
  8. package/artemis-main/Makefile +115 -0
  9. package/artemis-main/README.md +322 -0
  10. package/artemis-main/README_CN.md +320 -0
  11. package/artemis-main/apps/__init__.py +15 -0
  12. package/artemis-main/apps/admin_console/__init__.py +19 -0
  13. package/artemis-main/apps/admin_console/core/__init__.py +13 -0
  14. package/artemis-main/apps/admin_console/core/config.py +43 -0
  15. package/artemis-main/apps/admin_console/core/security.py +178 -0
  16. package/artemis-main/apps/admin_console/core/state.py +243 -0
  17. package/artemis-main/apps/admin_console/database/__init__.py +13 -0
  18. package/artemis-main/apps/admin_console/database/connection.py +60 -0
  19. package/artemis-main/apps/admin_console/database/repositories/__init__.py +13 -0
  20. package/artemis-main/apps/admin_console/database/repositories/session_repository.py +564 -0
  21. package/artemis-main/apps/admin_console/database/repositories/step_repository.py +512 -0
  22. package/artemis-main/apps/admin_console/database/repositories/trace_repository.py +133 -0
  23. package/artemis-main/apps/admin_console/index.html +3787 -0
  24. package/artemis-main/apps/admin_console/replay_manager.py +2585 -0
  25. package/artemis-main/apps/admin_console/routers/__init__.py +13 -0
  26. package/artemis-main/apps/admin_console/routers/media.py +198 -0
  27. package/artemis-main/apps/admin_console/routers/replay.py +91 -0
  28. package/artemis-main/apps/admin_console/routers/sessions.py +239 -0
  29. package/artemis-main/apps/admin_console/routers/steps.py +109 -0
  30. package/artemis-main/apps/admin_console/routers/stream.py +56 -0
  31. package/artemis-main/apps/admin_console/routers/system.py +761 -0
  32. package/artemis-main/apps/admin_console/routers/tasks.py +517 -0
  33. package/artemis-main/apps/admin_console/schemas/__init__.py +13 -0
  34. package/artemis-main/apps/admin_console/schemas/task_schema.py +47 -0
  35. package/artemis-main/apps/admin_console/server.py +508 -0
  36. package/artemis-main/apps/admin_console/services/__init__.py +13 -0
  37. package/artemis-main/apps/admin_console/services/device_stream_service.py +137 -0
  38. package/artemis-main/apps/admin_console/services/ipc_service.py +262 -0
  39. package/artemis-main/apps/admin_console/services/media_service.py +623 -0
  40. package/artemis-main/apps/admin_console/services/model_service.py +148 -0
  41. package/artemis-main/apps/admin_console/services/task_preset_catalog.py +514 -0
  42. package/artemis-main/apps/admin_console/services/task_queue_service.py +1719 -0
  43. package/artemis-main/apps/admin_console/services/worker_process_io.py +144 -0
  44. package/artemis-main/apps/showcase_ui/README.md +31 -0
  45. package/artemis-main/apps/showcase_ui/angular.json +132 -0
  46. package/artemis-main/apps/showcase_ui/package-lock.json +9472 -0
  47. package/artemis-main/apps/showcase_ui/package.json +39 -0
  48. package/artemis-main/apps/showcase_ui/proxy.conf.json +23 -0
  49. package/artemis-main/apps/showcase_ui/public/favicon.ico +0 -0
  50. package/artemis-main/apps/showcase_ui/public/logo.png +0 -0
  51. package/artemis-main/apps/showcase_ui/public/logo.svg +56 -0
  52. package/artemis-main/apps/showcase_ui/src/app/app.component.html +24 -0
  53. package/artemis-main/apps/showcase_ui/src/app/app.component.scss +49 -0
  54. package/artemis-main/apps/showcase_ui/src/app/app.component.spec.ts +42 -0
  55. package/artemis-main/apps/showcase_ui/src/app/app.component.ts +32 -0
  56. package/artemis-main/apps/showcase_ui/src/app/app.config.ts +29 -0
  57. package/artemis-main/apps/showcase_ui/src/app/app.routes.ts +27 -0
  58. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +108 -0
  59. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +1436 -0
  60. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.scss +3973 -0
  61. package/artemis-main/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.ts +2235 -0
  62. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.html +380 -0
  63. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.scss +1171 -0
  64. package/artemis-main/apps/showcase_ui/src/app/components/chat-interface/chat-interface.component.ts +270 -0
  65. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.html +433 -0
  66. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.scss +987 -0
  67. package/artemis-main/apps/showcase_ui/src/app/components/floating-video-player/floating-video-player.component.ts +632 -0
  68. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.html +130 -0
  69. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.scss +406 -0
  70. package/artemis-main/apps/showcase_ui/src/app/components/legacy-agent-stream/legacy-agent-stream.component.ts +74 -0
  71. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.scss +115 -0
  72. package/artemis-main/apps/showcase_ui/src/app/components/nav-switcher/nav-switcher.component.ts +52 -0
  73. package/artemis-main/apps/showcase_ui/src/app/core/data/smart-tasks.data.ts +334 -0
  74. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.spec.ts +55 -0
  75. package/artemis-main/apps/showcase_ui/src/app/core/i18n/language.service.ts +61 -0
  76. package/artemis-main/apps/showcase_ui/src/app/core/i18n/translate.pipe.ts +11 -0
  77. package/artemis-main/apps/showcase_ui/src/app/core/i18n/zh-cn.ts +651 -0
  78. package/artemis-main/apps/showcase_ui/src/app/core/models/markdown.model.ts +43 -0
  79. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.spec.ts +47 -0
  80. package/artemis-main/apps/showcase_ui/src/app/core/models/pro-tuning.model.ts +165 -0
  81. package/artemis-main/apps/showcase_ui/src/app/core/models/session.model.ts +85 -0
  82. package/artemis-main/apps/showcase_ui/src/app/core/models/stream.model.ts +246 -0
  83. package/artemis-main/apps/showcase_ui/src/app/core/models/system.model.ts +118 -0
  84. package/artemis-main/apps/showcase_ui/src/app/core/services/task-recommendation.service.ts +111 -0
  85. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.html +2130 -0
  86. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.scss +5204 -0
  87. package/artemis-main/apps/showcase_ui/src/app/pages/home/home.component.ts +1189 -0
  88. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.html +39 -0
  89. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.scss +151 -0
  90. package/artemis-main/apps/showcase_ui/src/app/pages/legacy-workspace/legacy-workspace.component.ts +58 -0
  91. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +201 -0
  92. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.scss +886 -0
  93. package/artemis-main/apps/showcase_ui/src/app/pages/workspace/workspace.component.ts +324 -0
  94. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.spec.ts +418 -0
  95. package/artemis-main/apps/showcase_ui/src/app/services/agent.service.ts +2012 -0
  96. package/artemis-main/apps/showcase_ui/src/app/services/system.service.spec.ts +173 -0
  97. package/artemis-main/apps/showcase_ui/src/app/services/system.service.ts +620 -0
  98. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.spec.ts +513 -0
  99. package/artemis-main/apps/showcase_ui/src/app/utils/action-formatter.util.ts +1015 -0
  100. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.spec.ts +85 -0
  101. package/artemis-main/apps/showcase_ui/src/app/utils/image-overlay.util.ts +662 -0
  102. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.spec.ts +70 -0
  103. package/artemis-main/apps/showcase_ui/src/app/utils/markdown-parser.util.ts +383 -0
  104. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.spec.ts +107 -0
  105. package/artemis-main/apps/showcase_ui/src/app/utils/recording-timeline.util.ts +123 -0
  106. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.spec.ts +92 -0
  107. package/artemis-main/apps/showcase_ui/src/app/utils/run-info.util.ts +116 -0
  108. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.spec.ts +640 -0
  109. package/artemis-main/apps/showcase_ui/src/app/utils/stream-aggregator.util.ts +1021 -0
  110. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.spec.ts +375 -0
  111. package/artemis-main/apps/showcase_ui/src/app/utils/tool-formatter.util.ts +1419 -0
  112. package/artemis-main/apps/showcase_ui/src/index.html +37 -0
  113. package/artemis-main/apps/showcase_ui/src/main.ts +22 -0
  114. package/artemis-main/apps/showcase_ui/src/styles.scss +60 -0
  115. package/artemis-main/apps/showcase_ui/tsconfig.app.json +23 -0
  116. package/artemis-main/apps/showcase_ui/tsconfig.json +27 -0
  117. package/artemis-main/apps/showcase_ui/tsconfig.spec.json +23 -0
  118. package/artemis-main/artemis/__init__.py +44 -0
  119. package/artemis-main/artemis/__main__.py +18 -0
  120. package/artemis-main/artemis/_legacy_imports.py +94 -0
  121. package/artemis-main/artemis/_version.py +30 -0
  122. package/artemis-main/artemis/agents/__init__.py +17 -0
  123. package/artemis-main/artemis/agents/checker/checker.json +9 -0
  124. package/artemis-main/artemis/agents/checker/checker.py +859 -0
  125. package/artemis-main/artemis/agents/diagnoser/diagnoser.json +23 -0
  126. package/artemis-main/artemis/agents/diagnoser/diagnoser.py +608 -0
  127. package/artemis-main/artemis/agents/explorer/constants.py +52 -0
  128. package/artemis-main/artemis/agents/explorer/explorer.json +57 -0
  129. package/artemis-main/artemis/agents/explorer/explorer.py +473 -0
  130. package/artemis-main/artemis/agents/explorer/geometry.py +73 -0
  131. package/artemis-main/artemis/agents/explorer/native_runner.py +878 -0
  132. package/artemis-main/artemis/agents/explorer/perception_tools.py +632 -0
  133. package/artemis-main/artemis/agents/explorer/run_setup.py +403 -0
  134. package/artemis-main/artemis/agents/explorer/screen_index.py +266 -0
  135. package/artemis-main/artemis/agents/explorer/tiers.py +110 -0
  136. package/artemis-main/artemis/agents/explorer/tool_declarations.py +394 -0
  137. package/artemis-main/artemis/agents/explorer/universal_runner.py +317 -0
  138. package/artemis-main/artemis/agents/flash/context_compressor.py +452 -0
  139. package/artemis-main/artemis/agents/flash/flash_runner.md +51 -0
  140. package/artemis-main/artemis/agents/flash/flash_summarizer.md +53 -0
  141. package/artemis-main/artemis/agents/flash/flash_summarizer_single.md +54 -0
  142. package/artemis-main/artemis/agents/flash/runner.py +1167 -0
  143. package/artemis-main/artemis/agents/flash/summarizer.py +511 -0
  144. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.md +8 -0
  145. package/artemis-main/artemis/agents/history_analyzer/history_analyzer.py +182 -0
  146. package/artemis-main/artemis/agents/image_processor/image_processor.md +74 -0
  147. package/artemis-main/artemis/agents/image_processor/image_processor.py +322 -0
  148. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.md +15 -0
  149. package/artemis-main/artemis/agents/log_analyzer/log_analyzer.py +403 -0
  150. package/artemis-main/artemis/agents/log_analyzer/log_reader_sub_agent.md +8 -0
  151. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.md +11 -0
  152. package/artemis-main/artemis/agents/log_analyzer/output_analyzer.py +163 -0
  153. package/artemis-main/artemis/agents/object_detector/object_detector.json +9 -0
  154. package/artemis-main/artemis/agents/object_detector/object_detector.py +211 -0
  155. package/artemis-main/artemis/agents/operator/operator.json +3 -0
  156. package/artemis-main/artemis/agents/operator/operator.py +1394 -0
  157. package/artemis-main/artemis/agents/operator/prompts.py +1064 -0
  158. package/artemis-main/artemis/agents/outputter/outputter.md +36 -0
  159. package/artemis-main/artemis/agents/outputter/outputter.py +552 -0
  160. package/artemis-main/artemis/agents/planner/planner.json +32 -0
  161. package/artemis-main/artemis/agents/planner/planner.py +436 -0
  162. package/artemis-main/artemis/agents/prompt_assembly.py +79 -0
  163. package/artemis-main/artemis/agents/summarizer/summarizer.py +190 -0
  164. package/artemis-main/artemis/agents/validator/action_execution.py +98 -0
  165. package/artemis-main/artemis/agents/validator/categories.py +33 -0
  166. package/artemis-main/artemis/agents/validator/execution_loop.py +488 -0
  167. package/artemis-main/artemis/agents/validator/incidents.py +120 -0
  168. package/artemis-main/artemis/agents/validator/pixel_safety_net.md +15 -0
  169. package/artemis-main/artemis/agents/validator/precondition_pixel.py +320 -0
  170. package/artemis-main/artemis/agents/validator/precondition_xml.py +672 -0
  171. package/artemis-main/artemis/agents/validator/tool_declarations.py +332 -0
  172. package/artemis-main/artemis/agents/validator/validator.py +188 -0
  173. package/artemis-main/artemis/agents/video_analyzer/__init__.py +15 -0
  174. package/artemis-main/artemis/agents/video_analyzer/audio_native.py +543 -0
  175. package/artemis-main/artemis/agents/video_analyzer/blackboard.py +821 -0
  176. package/artemis-main/artemis/agents/video_analyzer/chunk_agentic.py +455 -0
  177. package/artemis-main/artemis/agents/video_analyzer/chunk_conversation.py +491 -0
  178. package/artemis-main/artemis/agents/video_analyzer/chunk_native.py +437 -0
  179. package/artemis-main/artemis/agents/video_analyzer/conflict_resolution.py +137 -0
  180. package/artemis-main/artemis/agents/video_analyzer/gemini_files.py +162 -0
  181. package/artemis-main/artemis/agents/video_analyzer/native_coordinator.py +541 -0
  182. package/artemis-main/artemis/agents/video_analyzer/reliability.py +128 -0
  183. package/artemis-main/artemis/agents/video_analyzer/universal_engine.py +487 -0
  184. package/artemis-main/artemis/agents/video_analyzer/universal_tools.py +323 -0
  185. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.md +51 -0
  186. package/artemis-main/artemis/agents/video_analyzer/video_analyzer.py +875 -0
  187. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent.md +15 -0
  188. package/artemis-main/artemis/agents/video_analyzer/video_sub_agent_audio.md +12 -0
  189. package/artemis-main/artemis/clients/accessibility_client.py +438 -0
  190. package/artemis-main/artemis/clients/screen_client_factory.py +403 -0
  191. package/artemis-main/artemis/config/__init__.py +309 -0
  192. package/artemis-main/artemis/config/agent.py +1098 -0
  193. package/artemis-main/artemis/config/constants.py +151 -0
  194. package/artemis-main/artemis/config/core/__init__.py +24 -0
  195. package/artemis-main/artemis/config/core/engine.py +118 -0
  196. package/artemis-main/artemis/config/core/interpolator.py +78 -0
  197. package/artemis-main/artemis/config/llm.py +267 -0
  198. package/artemis-main/artemis/config/paths.py +244 -0
  199. package/artemis-main/artemis/config/runtime.py +197 -0
  200. package/artemis-main/artemis/config/settings.py +315 -0
  201. package/artemis-main/artemis/constants.py +47 -0
  202. package/artemis-main/artemis/context.py +289 -0
  203. package/artemis-main/artemis/controllers/__init__.py +41 -0
  204. package/artemis-main/artemis/controllers/controller_factory.py +41 -0
  205. package/artemis-main/artemis/controllers/device_controller.py +29 -0
  206. package/artemis-main/artemis/controllers/types.py +37 -0
  207. package/artemis-main/artemis/controllers/unified_controller.py +649 -0
  208. package/artemis-main/artemis/core/__init__.py +21 -0
  209. package/artemis-main/artemis/core/diagnostics/__init__.py +90 -0
  210. package/artemis-main/artemis/core/diagnostics/adb_keys.py +270 -0
  211. package/artemis-main/artemis/core/diagnostics/adb_server_connection.py +330 -0
  212. package/artemis-main/artemis/core/diagnostics/device_smoke.py +364 -0
  213. package/artemis-main/artemis/core/diagnostics/emulator_manager.py +501 -0
  214. package/artemis-main/artemis/core/diagnostics/engine.py +438 -0
  215. package/artemis-main/artemis/core/diagnostics/hierarchy_parity.py +216 -0
  216. package/artemis-main/artemis/core/diagnostics/probes/adb_probe.py +887 -0
  217. package/artemis-main/artemis/core/diagnostics/probes/base.py +45 -0
  218. package/artemis-main/artemis/core/diagnostics/probes/credentials_probe.py +296 -0
  219. package/artemis-main/artemis/core/diagnostics/probes/host_probe.py +427 -0
  220. package/artemis-main/artemis/core/diagnostics/probes/runtime_probe.py +169 -0
  221. package/artemis-main/artemis/core/diagnostics/probes/toolchain_probe.py +139 -0
  222. package/artemis-main/artemis/core/diagnostics/readiness.py +83 -0
  223. package/artemis-main/artemis/core/diagnostics/schema.py +118 -0
  224. package/artemis-main/artemis/core/tool_declaration.py +43 -0
  225. package/artemis-main/artemis/core/tool_failure.py +56 -0
  226. package/artemis-main/artemis/data_engine/context_vars.py +24 -0
  227. package/artemis-main/artemis/data_engine/engine.py +1615 -0
  228. package/artemis-main/artemis/data_engine/history_reader.py +142 -0
  229. package/artemis-main/artemis/data_engine/models.py +131 -0
  230. package/artemis-main/artemis/data_engine/storage.py +1245 -0
  231. package/artemis-main/artemis/data_engine/trace.py +727 -0
  232. package/artemis-main/artemis/drivers/__init__.py +48 -0
  233. package/artemis-main/artemis/drivers/android/adb_driver.py +507 -0
  234. package/artemis-main/artemis/drivers/android/hierarchy.py +64 -0
  235. package/artemis-main/artemis/drivers/android/input_ime.py +120 -0
  236. package/artemis-main/artemis/drivers/android/recorder.py +57 -0
  237. package/artemis-main/artemis/drivers/base.py +290 -0
  238. package/artemis-main/artemis/drivers/cloud/cloud_driver.py +117 -0
  239. package/artemis-main/artemis/drivers/factory.py +80 -0
  240. package/artemis-main/artemis/drivers/mock/mock_driver.py +198 -0
  241. package/artemis-main/artemis/drivers/types.py +67 -0
  242. package/artemis-main/artemis/graph/checkpoints.py +1243 -0
  243. package/artemis-main/artemis/graph/graph.py +1055 -0
  244. package/artemis-main/artemis/graph/perception.py +234 -0
  245. package/artemis-main/artemis/graph/state.py +151 -0
  246. package/artemis-main/artemis/graph/visibility.py +238 -0
  247. package/artemis-main/artemis/interfaces/cli/__init__.py +19 -0
  248. package/artemis-main/artemis/interfaces/cli/commands/__init__.py +15 -0
  249. package/artemis-main/artemis/interfaces/cli/commands/batch.py +278 -0
  250. package/artemis-main/artemis/interfaces/cli/commands/doctor.py +468 -0
  251. package/artemis-main/artemis/interfaces/cli/commands/helper.py +226 -0
  252. package/artemis-main/artemis/interfaces/cli/commands/init.py +193 -0
  253. package/artemis-main/artemis/interfaces/cli/commands/mcp.py +862 -0
  254. package/artemis-main/artemis/interfaces/cli/commands/run.py +463 -0
  255. package/artemis-main/artemis/interfaces/cli/commands/server.py +151 -0
  256. package/artemis-main/artemis/interfaces/cli/commands/server_lifecycle.py +328 -0
  257. package/artemis-main/artemis/interfaces/cli/commands/trace.py +111 -0
  258. package/artemis-main/artemis/interfaces/cli/commands/ui.py +404 -0
  259. package/artemis-main/artemis/interfaces/cli/main.py +109 -0
  260. package/artemis-main/artemis/interfaces/sdk/__init__.py +31 -0
  261. package/artemis-main/artemis/interfaces/sdk/client.py +19 -0
  262. package/artemis-main/artemis/interfaces/sdk/task.py +72 -0
  263. package/artemis-main/artemis/llm/__init__.py +25 -0
  264. package/artemis-main/artemis/llm/fake_model.py +179 -0
  265. package/artemis-main/artemis/llm/google/__init__.py +57 -0
  266. package/artemis-main/artemis/llm/google/provider.py +185 -0
  267. package/artemis-main/artemis/llm/google/usage.py +148 -0
  268. package/artemis-main/artemis/llm/parser.py +32 -0
  269. package/artemis-main/artemis/llm/reliability.py +267 -0
  270. package/artemis-main/artemis/llm/router.py +389 -0
  271. package/artemis-main/artemis/llm/schemas.py +36 -0
  272. package/artemis-main/artemis/llm/structured.py +196 -0
  273. package/artemis-main/artemis/main.py +58 -0
  274. package/artemis-main/artemis/mcp/__init__.py +20 -0
  275. package/artemis-main/artemis/mcp/action_executor.py +760 -0
  276. package/artemis-main/artemis/mcp/action_manifest.py +320 -0
  277. package/artemis-main/artemis/mcp/action_names.py +172 -0
  278. package/artemis-main/artemis/mcp/action_server.py +202 -0
  279. package/artemis-main/artemis/mcp/action_session.py +245 -0
  280. package/artemis-main/artemis/mcp/action_specs.py +868 -0
  281. package/artemis-main/artemis/mcp/action_types.py +86 -0
  282. package/artemis-main/artemis/mcp/actuators/__init__.py +21 -0
  283. package/artemis-main/artemis/mcp/actuators/adb.py +414 -0
  284. package/artemis-main/artemis/mcp/actuators/base.py +113 -0
  285. package/artemis-main/artemis/mcp/actuators/mock.py +70 -0
  286. package/artemis-main/artemis/mcp/adb_server.py +490 -0
  287. package/artemis-main/artemis/mcp/observation.py +133 -0
  288. package/artemis-main/artemis/mcp/xml_search_server.py +297 -0
  289. package/artemis-main/artemis/memory/__init__.py +90 -0
  290. package/artemis-main/artemis/memory/chunking.py +1843 -0
  291. package/artemis-main/artemis/memory/context_policy.py +226 -0
  292. package/artemis-main/artemis/memory/step_capsule.md +73 -0
  293. package/artemis-main/artemis/memory/step_memory.py +281 -0
  294. package/artemis-main/artemis/memory/transcript.py +798 -0
  295. package/artemis-main/artemis/platform/__init__.py +61 -0
  296. package/artemis-main/artemis/platform/base.py +104 -0
  297. package/artemis-main/artemis/platform/darwin.py +184 -0
  298. package/artemis-main/artemis/platform/linux.py +207 -0
  299. package/artemis-main/artemis/platform/windows.py +193 -0
  300. package/artemis-main/artemis/resources/__init__.py +46 -0
  301. package/artemis-main/artemis/runtime/__init__.py +108 -0
  302. package/artemis-main/artemis/runtime/adb_endpoint.py +173 -0
  303. package/artemis-main/artemis/runtime/awake_lease.py +283 -0
  304. package/artemis-main/artemis/runtime/awake_service.py +343 -0
  305. package/artemis-main/artemis/runtime/cancel_requests.py +254 -0
  306. package/artemis-main/artemis/runtime/daemon_client.py +422 -0
  307. package/artemis-main/artemis/runtime/device_lock.py +1013 -0
  308. package/artemis-main/artemis/runtime/device_pool.py +478 -0
  309. package/artemis-main/artemis/runtime/helper_manager.py +1030 -0
  310. package/artemis-main/artemis/runtime/process_probe.py +98 -0
  311. package/artemis-main/artemis/runtime/server_lifecycle.py +435 -0
  312. package/artemis-main/artemis/runtime/supervisor.py +121 -0
  313. package/artemis-main/artemis/runtime/trace_store.py +375 -0
  314. package/artemis-main/artemis/sdk/__init__.py +26 -0
  315. package/artemis-main/artemis/sdk/agent.py +485 -0
  316. package/artemis-main/artemis/sdk/builders/__init__.py +29 -0
  317. package/artemis-main/artemis/sdk/builders/agent_config_builder.py +474 -0
  318. package/artemis-main/artemis/sdk/constants.py +15 -0
  319. package/artemis-main/artemis/sdk/run_outcome.py +58 -0
  320. package/artemis-main/artemis/sdk/types/__init__.py +61 -0
  321. package/artemis-main/artemis/sdk/types/agent.py +89 -0
  322. package/artemis-main/artemis/sdk/utils.py +28 -0
  323. package/artemis-main/artemis/services/llm.py +1040 -0
  324. package/artemis-main/artemis/services/token_meter.py +267 -0
  325. package/artemis-main/artemis/telemetry/__init__.py +35 -0
  326. package/artemis-main/artemis/telemetry/models.py +64 -0
  327. package/artemis-main/artemis/telemetry/replay.py +34 -0
  328. package/artemis-main/artemis/telemetry/storage/json_storage.py +47 -0
  329. package/artemis-main/artemis/telemetry/tracer.py +85 -0
  330. package/artemis-main/artemis/toolchain/__init__.py +52 -0
  331. package/artemis-main/artemis/toolchain/descriptors.py +92 -0
  332. package/artemis-main/artemis/toolchain/resolver.py +185 -0
  333. package/artemis-main/artemis/tools/base.py +232 -0
  334. package/artemis-main/artemis/tools/command_tool.py +1240 -0
  335. package/artemis-main/artemis/tools/committee_prompts.json +5 -0
  336. package/artemis-main/artemis/tools/committee_tool.py +422 -0
  337. package/artemis-main/artemis/tools/diagnoser_submit_answer_tool.py +87 -0
  338. package/artemis-main/artemis/tools/diagnostic_tool.py +120 -0
  339. package/artemis-main/artemis/tools/explorer_tool.py +581 -0
  340. package/artemis-main/artemis/tools/history/__init__.py +398 -0
  341. package/artemis-main/artemis/tools/history/replay.py +120 -0
  342. package/artemis-main/artemis/tools/history/screenshot.py +139 -0
  343. package/artemis-main/artemis/tools/history/search.py +563 -0
  344. package/artemis-main/artemis/tools/image_processor_tool.py +124 -0
  345. package/artemis-main/artemis/tools/index.py +47 -0
  346. package/artemis-main/artemis/tools/log_tool.py +108 -0
  347. package/artemis-main/artemis/tools/mobile/launch_app.py +132 -0
  348. package/artemis-main/artemis/tools/mobile/log_utils.py +138 -0
  349. package/artemis-main/artemis/tools/mobile/ocr.py +202 -0
  350. package/artemis-main/artemis/tools/mobile/read_hierarchy.py +108 -0
  351. package/artemis-main/artemis/tools/mobile/read_logs.py +116 -0
  352. package/artemis-main/artemis/tools/mobile/search_logs.py +260 -0
  353. package/artemis-main/artemis/tools/object_detection_tool.py +244 -0
  354. package/artemis-main/artemis/tools/scratchpad.py +819 -0
  355. package/artemis-main/artemis/tools/system/adb_shell.py +31 -0
  356. package/artemis-main/artemis/tools/tool_wrapper.py +270 -0
  357. package/artemis-main/artemis/tools/video_tool.py +261 -0
  358. package/artemis-main/artemis/tools/wait_tool.py +97 -0
  359. package/artemis-main/artemis/utils/app_launch_utils.py +207 -0
  360. package/artemis-main/artemis/utils/coordinates.py +606 -0
  361. package/artemis-main/artemis/utils/credentials_validator.py +201 -0
  362. package/artemis-main/artemis/utils/cv_canvas.py +189 -0
  363. package/artemis-main/artemis/utils/cython_compat.py +45 -0
  364. package/artemis-main/artemis/utils/element_hit_test.py +80 -0
  365. package/artemis-main/artemis/utils/image_diff.py +335 -0
  366. package/artemis-main/artemis/utils/image_hash.py +80 -0
  367. package/artemis-main/artemis/utils/notes.py +470 -0
  368. package/artemis-main/artemis/utils/ocr_api.py +105 -0
  369. package/artemis-main/artemis/utils/ocr_xml_fusion.py +318 -0
  370. package/artemis-main/artemis/utils/plan_grammar.py +513 -0
  371. package/artemis-main/artemis/utils/python_executor.py +213 -0
  372. package/artemis-main/artemis/utils/startup_progress.py +60 -0
  373. package/artemis-main/artemis/utils/task_tree.py +1174 -0
  374. package/artemis-main/artemis/utils/text.py +37 -0
  375. package/artemis-main/artemis/utils/ui_filter.py +651 -0
  376. package/artemis-main/artemis/utils/video.py +685 -0
  377. package/artemis-main/artemis/utils/visualization.py +802 -0
  378. package/artemis-main/config/artemis.example.jsonc +12 -0
  379. package/artemis-main/mcp_server/README.md +111 -0
  380. package/artemis-main/mcp_server/__init__.py +21 -0
  381. package/artemis-main/mcp_server/__main__.py +20 -0
  382. package/artemis-main/mcp_server/background/__init__.py +15 -0
  383. package/artemis-main/mcp_server/background/task_runner.py +453 -0
  384. package/artemis-main/mcp_server/base.py +49 -0
  385. package/artemis-main/mcp_server/notifiers/__init__.py +74 -0
  386. package/artemis-main/mcp_server/notifiers/agentapi.py +289 -0
  387. package/artemis-main/mcp_server/notifiers/base.py +45 -0
  388. package/artemis-main/mcp_server/notifiers/composite.py +87 -0
  389. package/artemis-main/mcp_server/notifiers/desktop.py +108 -0
  390. package/artemis-main/mcp_server/notifiers/file.py +69 -0
  391. package/artemis-main/mcp_server/notifiers/script.py +90 -0
  392. package/artemis-main/mcp_server/notifiers/webhook.py +87 -0
  393. package/artemis-main/mcp_server/rules.md +68 -0
  394. package/artemis-main/mcp_server/server.py +106 -0
  395. package/artemis-main/mcp_server/tools/__init__.py +29 -0
  396. package/artemis-main/mcp_server/tools/device_state.py +107 -0
  397. package/artemis-main/mcp_server/tools/diagnose.py +1249 -0
  398. package/artemis-main/mcp_server/tools/inspect_trace.py +394 -0
  399. package/artemis-main/mcp_server/tools/task_manager.py +627 -0
  400. package/artemis-main/mcp_server/tools/task_runner.py +548 -0
  401. package/artemis-main/mcp_server/utils/__init__.py +15 -0
  402. package/artemis-main/mcp_server/utils/device_utils.py +180 -0
  403. package/artemis-main/mcp_server/utils/env_utils.py +65 -0
  404. package/artemis-main/packages/artemis-accessibility-helper/ArtemisAccessibilityHelper.apk +0 -0
  405. package/artemis-main/packages/artemis-accessibility-helper/app/build.gradle.kts +35 -0
  406. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/AndroidManifest.xml +49 -0
  407. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/A11yNode.java +274 -0
  408. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/ArtemisAccessibilityService.java +227 -0
  409. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/CommandServer.java +463 -0
  410. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/DisplayUtils.java +111 -0
  411. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/GestureController.java +232 -0
  412. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/HierarchyDumper.java +832 -0
  413. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenReceiver.java +36 -0
  414. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/TokenStore.java +38 -0
  415. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper/XmlUtils.java +102 -0
  416. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/values/strings.xml +4 -0
  417. package/artemis-main/packages/artemis-accessibility-helper/app/src/main/res/xml/accessibility_service_config.xml +10 -0
  418. package/artemis-main/packages/artemis-accessibility-helper/build.gradle.kts +3 -0
  419. package/artemis-main/packages/artemis-accessibility-helper/build_apk.sh +215 -0
  420. package/artemis-main/packages/artemis-accessibility-helper/debug.keystore +0 -0
  421. package/artemis-main/packages/artemis-accessibility-helper/gradle.properties +2 -0
  422. package/artemis-main/packages/artemis-accessibility-helper/helper_manifest.json +7 -0
  423. package/artemis-main/packages/artemis-accessibility-helper/settings.gradle.kts +17 -0
  424. package/artemis-main/packages/artemis-client/DESIGN.md +89 -0
  425. package/artemis-main/packages/artemis-client/README.md +112 -0
  426. package/artemis-main/packages/artemis-client/examples/basic.py +30 -0
  427. package/artemis-main/packages/artemis-client/pyproject.toml +36 -0
  428. package/artemis-main/packages/artemis-client/src/artemis_client/__init__.py +45 -0
  429. package/artemis-main/packages/artemis-client/src/artemis_client/client.py +401 -0
  430. package/artemis-main/packages/artemis-client/src/artemis_client/errors.py +66 -0
  431. package/artemis-main/packages/artemis-client/src/artemis_client/models.py +212 -0
  432. package/artemis-main/packages/artemis-client/src/artemis_client/py.typed +1 -0
  433. package/artemis-main/packages/artemis-client/src/artemis_client/transport.py +134 -0
  434. package/artemis-main/packages/artemis-client/tests/test_client.py +289 -0
  435. package/artemis-main/packages/artemis-client/tests/test_transport.py +85 -0
  436. package/artemis-main/pyproject.toml +259 -0
  437. package/artemis-main/pyright-core.json +17 -0
  438. package/artemis-main/scripts/check_dependency_sources.py +68 -0
  439. package/artemis-main/scripts/install_deps.ps1 +520 -0
  440. package/artemis-main/scripts/install_deps.sh +533 -0
  441. package/artemis-main/scripts/quality_ratchet.py +97 -0
  442. package/artemis-main/scripts/real_device_video_acceptance.py +483 -0
  443. package/artemis-main/scripts/run_edge_case_part.py +327 -0
  444. package/artemis-main/scripts/run_ui.py +242 -0
  445. package/artemis-main/scripts/start.ps1 +500 -0
  446. package/artemis-main/scripts/test_all_edge_cases.py +269 -0
  447. package/artemis-main/scripts/verify_concurrency_and_devices.py +385 -0
  448. package/artemis-main/setup.py +164 -0
  449. package/artemis-main/start.bat +35 -0
  450. package/artemis-main/start.sh +423 -0
  451. package/artemis-main/third_party/mobile_use/LICENSE +190 -0
  452. package/artemis-main/third_party/mobile_use/METADATA +27 -0
  453. package/artemis-main/third_party/mobile_use/NOTICE +12 -0
  454. package/artemis-main/third_party/mobile_use/__init__.py +19 -0
  455. package/artemis-main/third_party/mobile_use/agents/__init__.py +13 -0
  456. package/artemis-main/third_party/mobile_use/agents/hopper/__init__.py +13 -0
  457. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.md +22 -0
  458. package/artemis-main/third_party/mobile_use/agents/hopper/hopper.py +80 -0
  459. package/artemis-main/third_party/mobile_use/clients/__init__.py +13 -0
  460. package/artemis-main/third_party/mobile_use/clients/ui_automator_client.py +482 -0
  461. package/artemis-main/third_party/mobile_use/config/__init__.py +13 -0
  462. package/artemis-main/third_party/mobile_use/config/llm.py +183 -0
  463. package/artemis-main/third_party/mobile_use/config/output.py +184 -0
  464. package/artemis-main/third_party/mobile_use/context.py +119 -0
  465. package/artemis-main/third_party/mobile_use/controllers/__init__.py +13 -0
  466. package/artemis-main/third_party/mobile_use/controllers/platform_specific_commands_controller.py +151 -0
  467. package/artemis-main/third_party/mobile_use/controllers/types.py +92 -0
  468. package/artemis-main/third_party/mobile_use/controllers/unified_controller.py +183 -0
  469. package/artemis-main/third_party/mobile_use/graph.py +54 -0
  470. package/artemis-main/third_party/mobile_use/llm_service.py +193 -0
  471. package/artemis-main/third_party/mobile_use/main.py +130 -0
  472. package/artemis-main/third_party/mobile_use/sdk/__init__.py +13 -0
  473. package/artemis-main/third_party/mobile_use/sdk/agent.py +1060 -0
  474. package/artemis-main/third_party/mobile_use/sdk/builders/__init__.py +13 -0
  475. package/artemis-main/third_party/mobile_use/sdk/builders/agent_config_builder.py +225 -0
  476. package/artemis-main/third_party/mobile_use/sdk/builders/index.py +33 -0
  477. package/artemis-main/third_party/mobile_use/sdk/builders/task_request_builder.py +243 -0
  478. package/artemis-main/third_party/mobile_use/sdk/types/__init__.py +13 -0
  479. package/artemis-main/third_party/mobile_use/sdk/types/agent.py +98 -0
  480. package/artemis-main/third_party/mobile_use/sdk/types/exceptions.py +109 -0
  481. package/artemis-main/third_party/mobile_use/sdk/types/task.py +234 -0
  482. package/artemis-main/third_party/mobile_use/testing.py +59 -0
  483. package/artemis-main/third_party/mobile_use/tools/__init__.py +13 -0
  484. package/artemis-main/third_party/mobile_use/tools/index.py +44 -0
  485. package/artemis-main/third_party/mobile_use/tools/mobile/__init__.py +13 -0
  486. package/artemis-main/third_party/mobile_use/tools/mobile/launch_app.py +89 -0
  487. package/artemis-main/third_party/mobile_use/tools/tool_wrapper.py +52 -0
  488. package/artemis-main/third_party/mobile_use/utils/__init__.py +13 -0
  489. package/artemis-main/third_party/mobile_use/utils/app_launch_utils.py +217 -0
  490. package/artemis-main/third_party/mobile_use/utils/cli_helpers.py +60 -0
  491. package/artemis-main/third_party/mobile_use/utils/decorators.py +158 -0
  492. package/artemis-main/third_party/mobile_use/utils/file.py +31 -0
  493. package/artemis-main/third_party/mobile_use/utils/logger.py +216 -0
  494. package/artemis-main/third_party/mobile_use/utils/media.py +213 -0
  495. package/artemis-main/third_party/mobile_use/utils/shell_utils.py +39 -0
  496. package/artemis-main/third_party/mobile_use/utils/video.py +398 -0
  497. package/artemis-main/uv.lock +4908 -0
  498. package/bin/android-midscene-automation.js +1 -5
  499. package/bin/artemis-config.mjs +15 -0
  500. package/bin/artemis-embedded.py +56 -0
  501. package/bin/start-artemis.mjs +30 -0
  502. package/bin/start-dev.mjs +112 -0
  503. package/bin/test_artemis_embedded.py +44 -0
  504. package/package.json +10 -8
  505. package/server/artemis.ts +34 -0
  506. package/server/http-api.ts +13 -1
  507. package/src/App.vue +72 -23
  508. package/src/api.ts +5 -0
  509. package/src/appium-recorder/AppiumPage.vue +1 -1
  510. package/src/appium-recorder/components/ComponentInspector.vue +1 -1
  511. package/src/appium-recorder/components/FlowNodeSearch.vue +3 -3
  512. package/src/appium-recorder/components/ImageCheckDialog.vue +3 -3
  513. package/src/appium-recorder/components/RecorderWorkspace.vue +4 -4
  514. package/src/appium-recorder/flow-appearance.ts +4 -4
  515. package/src/components/AppSidebar.vue +8 -7
  516. package/src/components/MidsceneMaintenanceNotice.vue +17 -0
  517. package/src/components/analytics/AnalysisCharts.vue +4 -4
  518. package/src/components/analytics/ReportAnalysis.vue +1 -1
  519. package/src/components/generator/TestCaseFileUpload.vue +1 -1
  520. package/src/components/help/HelpCenter.vue +56 -37
  521. package/src/pages/AiGeneratorPage.vue +1 -1
  522. package/src/pages/ArtemisPage.vue +72 -0
  523. package/src/pages/ConfigPage.vue +18 -19
  524. package/src/style.css +369 -186
  525. package/src/types.ts +1 -1
@@ -0,0 +1,2012 @@
1
+ /**
2
+ * Copyright 2026 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
18
+ import { HttpClient } from '@angular/common/http';
19
+ import { Observable } from 'rxjs';
20
+
21
+ import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage } from '../core/models/session.model';
22
+ import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
23
+ import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
24
+ import { extractStepReplayFrames } from '../utils/action-formatter.util';
25
+ import { persistedStreamToSegments } from '../utils/stream-aggregator.util';
26
+ export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice };
27
+
28
+ const SESSION_CACHE_KEY = 'artemis.sessions.v1';
29
+
30
+ export interface VideoSegment {
31
+ url: string;
32
+ /** Back-to-back playlist timeline start (seconds); gaps between segments are not represented. */
33
+ start: number;
34
+ duration: number;
35
+ width: number;
36
+ height: number;
37
+ /** Session-relative first-frame offset in milliseconds (manifest v2). */
38
+ offset_ms?: number;
39
+ duration_ms?: number;
40
+ }
41
+
42
+ export type RecordingPlaybackStatus =
43
+ | 'idle'
44
+ | 'live'
45
+ | 'processing'
46
+ | 'ready'
47
+ | 'failed'
48
+ | 'unavailable';
49
+
50
+ export interface StartupProgressEvent {
51
+ session_id?: string;
52
+ stage: string;
53
+ message: string;
54
+ timestamp: number;
55
+ }
56
+
57
+ interface SessionVideoResponse {
58
+ session_id: string;
59
+ status?: 'processing' | 'ready' | 'failed' | 'unavailable';
60
+ has_video: boolean;
61
+ video_url: string | null;
62
+ video_segments?: VideoSegment[];
63
+ retry_after_ms?: number;
64
+ message?: string;
65
+ }
66
+
67
+ @Injectable({
68
+ providedIn: 'root'
69
+ })
70
+ export class AgentService {
71
+ private http = inject(HttpClient);
72
+ private zone = inject(NgZone);
73
+ private activePauseCardKey: string | null = null;
74
+
75
+ // Signals to expose state to components
76
+ private rawSessions = signal<Session[]>([]);
77
+ private pendingQueue = signal<Session[]>([]);
78
+ public activeTasks = signal<any[]>([]);
79
+ // Persistent tracking of active/pending sessions across polling boundaries
80
+ private activeSessionTracking = new Map<string, Session>();
81
+
82
+ public sessions = computed(() => {
83
+ const raw = this.rawSessions();
84
+ const pending = this.pendingQueue();
85
+ const status = this.agentStatus();
86
+ const goal = this.runningGoal();
87
+ const runId = this.runningSessionId();
88
+ const activeList = this.activeTasks();
89
+
90
+ const sessionMap = new Map<string, Session>();
91
+ // Lookup maps keep the per-session merge O(1) instead of scanning the
92
+ // pending/active lists multiple times for every raw session.
93
+ const pendingById = new Map(pending.map((p) => [p.session_id, p]));
94
+ const activeById = new Map(activeList.map((at) => [at.session_id, at]));
95
+
96
+ // 1. First add raw sessions from DB
97
+ raw.forEach((s) => {
98
+ const activeMatch = activeById.get(s.session_id);
99
+ const pendingMatch = pendingById.get(s.session_id);
100
+ const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
101
+ || !!activeMatch;
102
+ const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
103
+ let sStatus = s.status;
104
+ if (!isTerminal) {
105
+ const isPending = !!pendingMatch && !isCurrentActive;
106
+ sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
107
+ } else {
108
+ sStatus = (s.status === 'success') ? 'completed' : s.status;
109
+ }
110
+ let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
111
+ if (!serial && s.device_info) {
112
+ try {
113
+ const info = typeof s.device_info === 'string' ? JSON.parse(s.device_info) : s.device_info;
114
+ serial = info?.device_id || info?.device_serial || null;
115
+ } catch {
116
+ // ignore
117
+ }
118
+ }
119
+ const finalSession: Session = {
120
+ ...s,
121
+ status: sStatus,
122
+ device_serial: serial,
123
+ model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
124
+ };
125
+ sessionMap.set(s.session_id, finalSession);
126
+ if (isTerminal) {
127
+ this.activeSessionTracking.delete(s.session_id);
128
+ } else if (sStatus === 'running' || sStatus === 'paused' || sStatus === 'pending') {
129
+ this.activeSessionTracking.set(s.session_id, finalSession);
130
+ }
131
+ });
132
+
133
+ // 2. Add pending queue sessions if not yet in raw sessions
134
+ pending.forEach((p) => {
135
+ if (!sessionMap.has(p.session_id)) {
136
+ const activeMatch = activeById.get(p.session_id);
137
+ const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
138
+ || !!activeMatch;
139
+ const finalPending: Session = {
140
+ ...p,
141
+ status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
142
+ device_serial: p.device_serial || p.device_id || activeMatch?.device_id || null
143
+ };
144
+ sessionMap.set(p.session_id, finalPending);
145
+ this.activeSessionTracking.set(p.session_id, finalPending);
146
+ }
147
+ });
148
+
149
+ // 3. Ensure all currently active running sessions are present (multi-device & external runs)
150
+ if (activeList.length > 0) {
151
+ activeList.forEach((at) => {
152
+ const sid = at.session_id || `active-${at.device_id}`;
153
+ const existing = sessionMap.get(sid);
154
+ if (!existing) {
155
+ const newSession: Session = {
156
+ session_id: sid,
157
+ initial_goal: at.goal || goal || '',
158
+ start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
159
+ status: 'running',
160
+ model_info: this.activeModel() || undefined,
161
+ device_serial: at.device_id || null
162
+ };
163
+ sessionMap.set(sid, newSession);
164
+ this.activeSessionTracking.set(sid, newSession);
165
+ } else if (existing.status === 'pending') {
166
+ const updated: Session = {
167
+ ...existing,
168
+ status: 'running',
169
+ device_serial: at.device_id || existing.device_serial
170
+ };
171
+ sessionMap.set(sid, updated);
172
+ this.activeSessionTracking.set(sid, updated);
173
+ }
174
+ });
175
+ } else if ((status === 'running' || status === 'paused') && runId && !sessionMap.has(runId)) {
176
+ const activeSession: Session = {
177
+ session_id: runId,
178
+ initial_goal: goal || '',
179
+ start_time: Date.now() / 1000,
180
+ status,
181
+ model_info: this.activeModel() || undefined
182
+ };
183
+ sessionMap.set(runId, activeSession);
184
+ this.activeSessionTracking.set(runId, activeSession);
185
+ }
186
+
187
+ // 4. Bridge transient queue-to-running transition gaps
188
+ this.activeSessionTracking.forEach((ts, sid) => {
189
+ if (!sessionMap.has(sid)) {
190
+ sessionMap.set(sid, {
191
+ ...ts,
192
+ status: 'running'
193
+ });
194
+ }
195
+ });
196
+
197
+ return Array.from(sessionMap.values()).sort((a, b) => b.start_time - a.start_time);
198
+ });
199
+
200
+ public currentSessionId = signal<string | null>(null);
201
+ public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
202
+ public isSessionContentLoading = signal<boolean>(false);
203
+ public agentStatus = signal<string>('idle'); // Status of the agent runner process
204
+ public runningSessionId = signal<string | null>(null);
205
+ public runningGoal = signal<string | null>(null);
206
+ public isPaused = signal<boolean>(false);
207
+ public pausedError = signal<string | null>(null);
208
+ public isRetrying = signal<boolean>(false);
209
+ public retryMessage = signal<string | null>(null);
210
+ /** Architecture / LLM of the task the status poll reports as running. */
211
+ public activeModel = signal<{ name: string; id: string; provider: string } | null>(null);
212
+ public userPinnedSessionId = signal<string | null>(null); // Pinned session if user explicitly selected a non-running task
213
+ public startupProgressBySession = signal<Record<string, StartupProgressEvent[]>>({});
214
+ private pendingStartupProgress = signal<StartupProgressEvent[]>([]);
215
+
216
+ public currentStartupProgress = computed(() => {
217
+ const sessionId = this.currentSessionId();
218
+ if (sessionId) {
219
+ const normalized = String(sessionId).trim().toLowerCase();
220
+ const bySession = this.startupProgressBySession();
221
+ const direct = bySession[sessionId];
222
+ if (direct?.length) return direct;
223
+ const matched = Object.entries(bySession).find(
224
+ ([key]) => key.trim().toLowerCase() === normalized
225
+ );
226
+ if (matched?.[1]?.length) return matched[1];
227
+ }
228
+ return this.pendingStartupProgress();
229
+ });
230
+
231
+ // Notes and Tab States
232
+ public currentNotes = signal<Record<string, string>>({});
233
+ public selectedNoteKey = signal<string>('task_plan.md');
234
+ public activeTab = signal<'tasks' | 'notes'>('tasks');
235
+
236
+ // Video Replay Floating Window States
237
+ public isVideoWindowOpen = signal<boolean>(false);
238
+ public isVideoMinimized = signal<boolean>(false);
239
+ public activeVideoUrl = signal<string | null>(null);
240
+ public activeVideoSegments = signal<VideoSegment[]>([]);
241
+ public activeVideoTitle = signal<string>('');
242
+ public isVideoLoading = signal<boolean>(false);
243
+ public recordingPlaybackStatus = signal<RecordingPlaybackStatus>('idle');
244
+ public recordingPlaybackMessage = signal<string>('');
245
+ public shouldAutoplayVideo = signal<boolean>(false);
246
+ public videoSeekRequest = signal<{ seconds: number; requestId: number } | null>(null);
247
+ public stepSeekRequest = signal<{ index: number; requestId: number } | null>(null);
248
+ public playerMode = signal<'video' | 'steps'>('video');
249
+ public streamResetEvent = signal<LLMStreamResetEventData | null>(null);
250
+ private activeVideoSessionId: string | null = null;
251
+ private videoSeekRequestId = 0;
252
+ private stepSeekRequestId = 0;
253
+ private videoRequestGeneration = 0;
254
+ private videoRetryTimer: ReturnType<typeof setTimeout> | null = null;
255
+ private videoWaitStartedAt = 0;
256
+
257
+ /**
258
+ * Step logs only. Streaming text chunks cannot change replay frames, so the
259
+ * custom equality keeps downstream frame extraction from re-running on every
260
+ * llm_stream update of the sessionLogs signal.
261
+ */
262
+ private stepLogsForReplay = computed<any[]>(
263
+ () => this.sessionLogs().filter(
264
+ (log) => log && (log.type === 'step_updated' || log.type === 'step_recorded')
265
+ ),
266
+ { equal: (a, b) => a.length === b.length && a.every((log, i) => log === b[i]) }
267
+ );
268
+
269
+ /**
270
+ * Computed step replay frames from current session logs
271
+ */
272
+ public currentSessionStepFrames = computed<StepReplayFrame[]>(() => {
273
+ return extractStepReplayFrames(this.stepLogsForReplay());
274
+ });
275
+
276
+ public hasCurrentSessionStepFrames = computed<boolean>(() => {
277
+ return this.currentSessionStepFrames().length > 0;
278
+ });
279
+
280
+ public setPlayerMode(mode: 'video' | 'steps'): void {
281
+ this.playerMode.set(mode);
282
+ }
283
+
284
+ public togglePlayerMode(): void {
285
+ this.playerMode.update((mode) => (mode === 'video' ? 'steps' : 'video'));
286
+ }
287
+
288
+ public requestStepSeek(index: number): void {
289
+ this.stepSeekRequestId++;
290
+ this.stepSeekRequest.set({ index, requestId: this.stepSeekRequestId });
291
+ }
292
+
293
+ /**
294
+ * Clear user-pinned selection so subsequent runs automatically follow active runner
295
+ */
296
+ public clearUserPinnedSession(): void {
297
+ this.userPinnedSessionId.set(null);
298
+ }
299
+
300
+ /**
301
+ * Computed active/viewed session
302
+ */
303
+ public currentSession = computed(() => {
304
+ const curId = this.currentSessionId();
305
+ if (!curId) return null;
306
+ return this.sessions().find(s => s.session_id === curId) || null;
307
+ });
308
+
309
+ /**
310
+ * Architecture / LLM of the session being viewed.
311
+ *
312
+ * Each session row carries its own `model_info` (resolved by the backend),
313
+ * so a Pro session stays "Pro" while a Flash task runs elsewhere. Only a
314
+ * session that has not reached the list yet falls back to the running model.
315
+ */
316
+ public viewedModel = computed<ModelInfo | null>(() => {
317
+ const current = this.currentSession();
318
+ if (current?.model_info) return current.model_info;
319
+ return this.activeModel();
320
+ });
321
+
322
+ /**
323
+ * Computed whether the currently viewed session is actively running or paused.
324
+ * Returns false when viewing completed/failed/cancelled tasks, or when no session is selected.
325
+ */
326
+ public isCurrentSessionRunning = computed(() => {
327
+ const curId = this.currentSessionId();
328
+ if (!curId) return false;
329
+ const session = this.currentSession();
330
+ if (session) {
331
+ return session.status === 'running' || session.status === 'paused';
332
+ }
333
+ // Fallback if session was just submitted/selected but not yet merged into sessions list
334
+ const isActiveStatus = this.agentStatus() === 'running' || this.agentStatus() === 'paused';
335
+ if (isActiveStatus && this.runningSessionId() === curId) {
336
+ return true;
337
+ }
338
+ if (this.activeTasks().some((at) => at.session_id === curId)) {
339
+ return true;
340
+ }
341
+ return false;
342
+ });
343
+
344
+ /**
345
+ * Computed video URL for the currently viewed session
346
+ */
347
+ public currentSessionVideoUrl = computed(() => {
348
+ const curId = this.currentSessionId();
349
+ if (!curId) return null;
350
+ const session = this.sessions().find(s => s.session_id === curId);
351
+ return session?.video_url || null;
352
+ });
353
+
354
+ public currentSessionRecordingStatus = computed(() => {
355
+ const session = this.currentSession();
356
+ const status = session?.recording_status;
357
+ return status === 'recording' || status === 'finalizing' || status === 'processing'
358
+ ? 'processing'
359
+ : status;
360
+ });
361
+
362
+ private eventSource: EventSource | null = null;
363
+ private statusInterval: any = null;
364
+ private sessionLoadGeneration = 0;
365
+ private sessionSnapshotRequestId = 0;
366
+ private sessionSnapshotAppliedId = 0;
367
+ private pendingSnapshotRequests = new Set<number>();
368
+
369
+ // Streaming chunks are coalesced into one signal update per short window so a
370
+ // chatty LLM stream cannot force a full recompute pass per SSE chunk.
371
+ private pendingStreamChunks = new Map<string, { execId: any; stepId: any; sessionId?: string | null; parentTraceId?: any; streamType: string; chunk: string }>();
372
+ private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
373
+ private lastQueueSignature: string | null = null;
374
+ private lastActiveTasksSignature: string | null = null;
375
+ private lastPersistedSessionsJson: string | null = null;
376
+ private onVisibilityChange = () => {
377
+ if (typeof document !== 'undefined' && !document.hidden) {
378
+ this.fetchStatus();
379
+ this.fetchSessions();
380
+ }
381
+ };
382
+
383
+ constructor() {
384
+ this.restoreSessionsCache();
385
+ this.fetchSessions();
386
+ this.startStatusPolling();
387
+ this.ensureLiveStream();
388
+ if (typeof document !== 'undefined') {
389
+ document.addEventListener('visibilitychange', this.onVisibilityChange);
390
+ }
391
+ inject(DestroyRef).onDestroy(() => this.destroy());
392
+ }
393
+
394
+ /**
395
+ * Check if any task is currently running
396
+ */
397
+ public isRunningTask = computed(() => {
398
+ if (this.agentStatus() === 'running' || this.agentStatus() === 'paused') return true;
399
+ return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
400
+ });
401
+
402
+ /**
403
+ * Effective Pro tuning defaults (verification level, explorer mode) from the
404
+ * backend agent config, so the launcher sliders start where the config is.
405
+ */
406
+ public getProTuningDefaults(): Observable<ProTuningDefaults> {
407
+ return this.http.get<ProTuningDefaults>('/api/run/defaults');
408
+ }
409
+
410
+ /** Session-wide token totals, live executor context size and the run's tuning. */
411
+ public getSessionUsage(sessionId: string): Observable<SessionUsage> {
412
+ return this.http.get<SessionUsage>(`/api/sessions/${encodeURIComponent(sessionId)}/usage`);
413
+ }
414
+
415
+ /**
416
+ * Run a new task by submitting to backend queue
417
+ */
418
+ public runTask(
419
+ goal: string,
420
+ profile: string = 'flash',
421
+ expectedOutput?: string,
422
+ enableOutputter?: boolean,
423
+ proTuning?: ProTuningOptions
424
+ ): Observable<any> {
425
+ return new Observable((obs) => {
426
+ const submittedEvent: StartupProgressEvent = {
427
+ stage: 'submitting',
428
+ message: 'Submitting the task',
429
+ timestamp: Date.now() / 1000
430
+ };
431
+ this.pendingStartupProgress.set([submittedEvent]);
432
+ const payload: any = { goal, profile };
433
+ if (expectedOutput && expectedOutput.trim()) {
434
+ payload.expected_output = expectedOutput.trim();
435
+ }
436
+ if (enableOutputter !== undefined) {
437
+ payload.enable_outputter = enableOutputter;
438
+ }
439
+ if (proTuning?.verificationLevel) {
440
+ payload.verification_level = proTuning.verificationLevel;
441
+ }
442
+ if (proTuning?.explorerMode) {
443
+ payload.explorer_mode = proTuning.explorerMode;
444
+ }
445
+ this.clearUserPinnedSession();
446
+ this.http.post<any>('/api/run', payload).subscribe({
447
+ next: (res) => {
448
+ if (res && res.tasks && res.tasks.length > 0) {
449
+ const newSessionId = res.tasks[0].session_id;
450
+ if (newSessionId) {
451
+ this.appendStartupProgress(
452
+ { ...submittedEvent, session_id: newSessionId },
453
+ newSessionId
454
+ );
455
+ this.pendingStartupProgress.set([]);
456
+ const isCurrentlyRunning =
457
+ this.agentStatus() === 'running' ||
458
+ this.sessions().some((s) => s.status === 'running');
459
+ const activeSessionId = this.runningSessionId()
460
+ || this.sessions().find((s) => s.status === 'running' || s.status === 'paused')?.session_id;
461
+ // The status poll can observe the new runner before /api/run
462
+ // returns. In that case it is still the task we just submitted,
463
+ // not an older task that should remain selected.
464
+ if (!isCurrentlyRunning || activeSessionId === newSessionId) {
465
+ this.selectSession(newSessionId, false);
466
+ }
467
+ }
468
+ }
469
+ this.pendingStartupProgress.set([]);
470
+ obs.next(res);
471
+ obs.complete();
472
+ },
473
+ error: (err) => {
474
+ this.pendingStartupProgress.set([]);
475
+ obs.error(err);
476
+ }
477
+ });
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Stop the currently viewed task (or specified task), and optionally clear queue.
483
+ * Supports stopping any task across any ingress/platform (Web, CLI, MCP, SDK).
484
+ */
485
+ public stopTask(targetOrStopAll?: string | boolean | null, stopAll: boolean = false): void {
486
+ let targetSessionId: string | null = null;
487
+ let effectiveStopAll = stopAll;
488
+
489
+ if (typeof targetOrStopAll === 'boolean') {
490
+ effectiveStopAll = targetOrStopAll;
491
+ } else if (typeof targetOrStopAll === 'string' && targetOrStopAll.trim()) {
492
+ targetSessionId = targetOrStopAll.trim();
493
+ } else {
494
+ // Default to currently viewed session, or currently running session
495
+ targetSessionId = this.currentSessionId() || this.runningSessionId() || null;
496
+ }
497
+
498
+ if (!effectiveStopAll && !targetSessionId) {
499
+ targetSessionId = this.sessions().find(
500
+ (session) => session.status === 'running' || session.status === 'paused'
501
+ )?.session_id || null;
502
+ }
503
+
504
+ // Keep the task's terminal state stable while the stop request and the
505
+ // persisted session update cross the network. Clearing the global runner
506
+ // state first used to make the session merger infer "completed" from the
507
+ // still-stale DB row, causing a brief completed -> cancelled flicker.
508
+ if (targetSessionId) {
509
+ this.setSessionStatus(targetSessionId, 'cancelled');
510
+ }
511
+
512
+ // Determine if other active sessions are still executing across multiple devices
513
+ const otherRunningSessions = this.sessions().filter(
514
+ (s) => (s.status === 'running' || s.status === 'paused') && s.session_id !== targetSessionId
515
+ );
516
+
517
+ // Apply optimistic updates: only set idle if effectiveStopAll is true or no other tasks are running
518
+ if (effectiveStopAll || otherRunningSessions.length === 0) {
519
+ this.agentStatus.set('idle');
520
+ this.runningSessionId.set(null);
521
+ this.runningGoal.set(null);
522
+ } else if (this.runningSessionId() === targetSessionId) {
523
+ this.runningSessionId.set(otherRunningSessions[0].session_id);
524
+ this.runningGoal.set(otherRunningSessions[0].initial_goal || null);
525
+ }
526
+
527
+ this.isPaused.set(false);
528
+ this.pausedError.set(null);
529
+ this.isRetrying.set(false);
530
+ this.invalidateStatusSignatures();
531
+ if (effectiveStopAll) {
532
+ this.pendingQueue.set([]);
533
+ }
534
+
535
+ // Mark active streaming / pending logs as finished if viewing target session
536
+ if (!targetSessionId || this.currentSessionId() === targetSessionId) {
537
+ this.flushStreamChunks();
538
+ this.sessionLogs.update((logs) => {
539
+ if (!logs.some((l) => l.type === 'llm_stream' && !l.data?.isCompleted)) {
540
+ return logs;
541
+ }
542
+ return logs.map((l) => {
543
+ if (l.type === 'llm_stream' && !l.data?.isCompleted) {
544
+ return { ...l, data: { ...l.data, isCompleted: true } };
545
+ }
546
+ return l;
547
+ });
548
+ });
549
+ }
550
+
551
+ // Optimistically remove from activeTasks
552
+ if (targetSessionId) {
553
+ this.activeTasks.update((list) => list.filter((at) => at.session_id !== targetSessionId));
554
+ }
555
+
556
+ let url = `/api/stop?all=${effectiveStopAll}`;
557
+ if (!effectiveStopAll && targetSessionId) {
558
+ url += `&session_id=${encodeURIComponent(targetSessionId)}`;
559
+ }
560
+
561
+ const payload: any = { all: effectiveStopAll };
562
+ if (targetSessionId) {
563
+ payload.session_id = targetSessionId;
564
+ }
565
+
566
+ this.http.post<any>(url, payload).subscribe({
567
+ next: () => {
568
+ this.fetchStatus();
569
+ this.fetchSessions();
570
+ },
571
+ error: (err) => {
572
+ console.error('Failed to stop task:', err);
573
+ this.fetchSessions();
574
+ this.fetchStatus();
575
+ }
576
+ });
577
+ }
578
+
579
+ /**
580
+ * Resume paused task
581
+ */
582
+ public resumeTask(): void {
583
+ this.http.post<{ status?: string }>('/api/resume', {}).subscribe({
584
+ next: (response) => {
585
+ if (response?.status !== 'resumed') {
586
+ // A stale recovery control must not optimistically change the task
587
+ // back to running when the backend says there is nothing to resume.
588
+ this.fetchStatus();
589
+ return;
590
+ }
591
+ const resumedSessionId = this.runningSessionId();
592
+ this.isPaused.set(false);
593
+ this.pausedError.set(null);
594
+ this.agentStatus.set('running');
595
+ this.setSessionStatus(resumedSessionId, 'running');
596
+ this.fetchStatus();
597
+ },
598
+ error: (err) => {
599
+ console.error('Failed to resume task:', err);
600
+ }
601
+ });
602
+ }
603
+
604
+ /**
605
+ * Fetch all past and active sessions from the backend
606
+ */
607
+ public fetchSessions(): void {
608
+ this.http.get<Session[]>('/api/sessions').subscribe({
609
+ next: (data) => {
610
+ this.rawSessions.set(data);
611
+ this.persistSessionsCache(data);
612
+ // On initial load, if nothing is selected, not pinned, not running, and sessions exist, select latest
613
+ if (!this.currentSessionId() && !this.userPinnedSessionId() && this.agentStatus() !== 'running' && data.length > 0) {
614
+ this.selectSession(data[0].session_id, false);
615
+ }
616
+ },
617
+ error: (err) => {
618
+ console.error('Failed to fetch sessions from backend:', err);
619
+ }
620
+ });
621
+ }
622
+
623
+ /**
624
+ * Delete an individual session / task
625
+ */
626
+ public deleteSession(sessionId: string): Observable<any> {
627
+ // 1. Optimistically update local session state immediately
628
+ this.invalidateStatusSignatures();
629
+ this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId));
630
+ this.persistSessionsCache(this.rawSessions());
631
+ this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId));
632
+
633
+ if (this.userPinnedSessionId() === sessionId) {
634
+ this.userPinnedSessionId.set(null);
635
+ }
636
+
637
+ if (this.currentSessionId() === sessionId) {
638
+ this.selectSession('', false);
639
+ if (this.isVideoWindowOpen()) {
640
+ this.closeVideoPlayer();
641
+ }
642
+ const runningId = this.runningSessionId();
643
+ if (runningId && this.agentStatus() === 'running') {
644
+ this.selectSession(runningId, false);
645
+ }
646
+ }
647
+
648
+ // 2. Send API request
649
+ return new Observable((obs) => {
650
+ this.http.post<any>(`/api/sessions/${sessionId}/delete`, {}).subscribe({
651
+ next: (res) => {
652
+ this.fetchSessions();
653
+ this.fetchStatus();
654
+ obs.next(res);
655
+ obs.complete();
656
+ },
657
+ error: (err) => {
658
+ console.error(`Failed to delete session ${sessionId}:`, err);
659
+ this.fetchSessions();
660
+ this.fetchStatus();
661
+ obs.error(err);
662
+ }
663
+ });
664
+ });
665
+ }
666
+
667
+ /**
668
+ * Clear all sessions, tasks, and history
669
+ */
670
+ public clearAllHistory(): Observable<any> {
671
+ // 1. Optimistically clear all local session state immediately
672
+ this.invalidateStatusSignatures();
673
+ this.userPinnedSessionId.set(null);
674
+ this.rawSessions.set([]);
675
+ this.clearSessionsCache();
676
+ this.pendingQueue.set([]);
677
+ this.selectSession('', false);
678
+ if (this.isVideoWindowOpen()) {
679
+ this.closeVideoPlayer();
680
+ }
681
+
682
+ // 2. Send API request
683
+ return new Observable((obs) => {
684
+ this.http.post<any>('/api/cleanup', {}).subscribe({
685
+ next: (res) => {
686
+ this.fetchSessions();
687
+ this.fetchStatus();
688
+ obs.next(res);
689
+ obs.complete();
690
+ },
691
+ error: (err) => {
692
+ console.error('Failed to cleanup history:', err);
693
+ this.fetchSessions();
694
+ this.fetchStatus();
695
+ obs.error(err);
696
+ }
697
+ });
698
+ });
699
+ }
700
+
701
+ /**
702
+ * Connect to the Server-Sent Events stream for a specific session
703
+ * @param sessionId The ID of the session to select
704
+ * @param isUserAction Whether this selection was explicitly triggered by user interaction
705
+ */
706
+ public selectSession(sessionId: string, isUserAction: boolean = false): void {
707
+ if (isUserAction) {
708
+ if (sessionId && sessionId === this.runningSessionId() && this.agentStatus() === 'running') {
709
+ // User explicitly clicked the currently running task -> follow live runner
710
+ this.userPinnedSessionId.set(null);
711
+ } else if (sessionId) {
712
+ // User explicitly clicked a non-running or historical task -> pin to it
713
+ this.userPinnedSessionId.set(sessionId);
714
+ } else {
715
+ this.userPinnedSessionId.set(null);
716
+ }
717
+ }
718
+
719
+ if (!sessionId) {
720
+ this.sessionLoadGeneration++;
721
+ this.pendingSnapshotRequests.clear();
722
+ this.discardPendingStreamChunks();
723
+ this.isSessionContentLoading.set(false);
724
+ this.currentSessionId.set(null);
725
+ this.sessionLogs.set([]);
726
+ this.currentNotes.set({});
727
+ return;
728
+ }
729
+
730
+ if (this.currentSessionId() === sessionId) {
731
+ return; // Already selected
732
+ }
733
+
734
+ const loadGeneration = ++this.sessionLoadGeneration;
735
+ this.pendingSnapshotRequests.clear();
736
+ this.discardPendingStreamChunks();
737
+ this.sessionSnapshotAppliedId = 0;
738
+ this.isSessionContentLoading.set(true);
739
+ this.currentSessionId.set(sessionId);
740
+ this.sessionLogs.set([]); // Reset logs for new session selection
741
+ this.activePauseCardKey = null;
742
+ this.isPaused.set(false);
743
+ this.pausedError.set(null);
744
+ this.fetchNotes(sessionId);
745
+ this.fetchChecks(sessionId);
746
+
747
+ // If video window is currently open, dynamically sync/refresh video for new session
748
+ if (this.isVideoWindowOpen()) {
749
+ this.openVideoPlayer(sessionId);
750
+ }
751
+
752
+ console.debug(`Selecting session: ${sessionId}`);
753
+ // Start reading the persisted snapshot immediately
754
+ this.backfillSessionSteps(sessionId, loadGeneration);
755
+ this.ensureLiveStream();
756
+ }
757
+
758
+ /**
759
+ * Ensure persistent single-channel live stream is established via /api/stream.
760
+ * Eliminates reconnect tearing, race conditions, and disconnect dropouts.
761
+ */
762
+ public ensureLiveStream(): void {
763
+ if (this.eventSource) {
764
+ return;
765
+ }
766
+
767
+ console.debug('Establishing persistent unified live stream via /api/stream');
768
+ // The stream is registered outside the Angular zone: high-frequency SSE
769
+ // callbacks must not schedule a change-detection pass each. Signal writes
770
+ // still notify the render scheduler, so the UI stays live.
771
+ this.zone.runOutsideAngular(() => {
772
+ this.eventSource = new EventSource('/api/stream');
773
+
774
+ this.eventSource.addEventListener('info', () => {
775
+ // Reconcile current session if active
776
+ const curId = this.currentSessionId();
777
+ if (curId) {
778
+ this.backfillSessionSteps(curId);
779
+ }
780
+ });
781
+
782
+ this.eventSource.addEventListener('keep-alive', () => {
783
+ // Keep-alive tick
784
+ });
785
+
786
+ const eventTypes = [
787
+ 'llm_stream',
788
+ 'llm_stream_reset',
789
+ 'trace_recorded',
790
+ 'step_recorded',
791
+ 'step_updated',
792
+ 'background_tasks_updated',
793
+ 'task_paused',
794
+ 'task_resumed',
795
+ 'llm_retrying',
796
+ 'startup_progress',
797
+ 'session_started',
798
+ 'session_ended',
799
+ 'recording_ready',
800
+ 'recording_failed',
801
+ 'checker_event'
802
+ ];
803
+
804
+ eventTypes.forEach((eventType) => {
805
+ this.eventSource?.addEventListener(eventType, (event: MessageEvent) => {
806
+ try {
807
+ const parsedData = JSON.parse(event.data);
808
+ const evtSessionId = parsedData?.session_id;
809
+
810
+ // Preserve ordering: buffered stream text must land before any
811
+ // non-stream event is appended to the session logs.
812
+ if (eventType !== 'llm_stream') {
813
+ this.flushStreamChunks();
814
+ }
815
+
816
+ if (eventType === 'recording_ready' || eventType === 'recording_failed') {
817
+ if (
818
+ this.isVideoWindowOpen()
819
+ && evtSessionId
820
+ && String(evtSessionId) === String(this.activeVideoSessionId)
821
+ ) {
822
+ if (eventType === 'recording_ready') {
823
+ this.refreshActiveRecording(true);
824
+ } else {
825
+ this.cancelVideoRetry();
826
+ this.isVideoLoading.set(false);
827
+ this.activeVideoUrl.set(null);
828
+ this.activeVideoSegments.set([]);
829
+ this.recordingPlaybackStatus.set('failed');
830
+ this.recordingPlaybackMessage.set(
831
+ parsedData?.error || 'Recording finalization failed.'
832
+ );
833
+ }
834
+ }
835
+ return;
836
+ }
837
+
838
+ if (eventType === 'background_tasks_updated') {
839
+ this.fetchStatus();
840
+ return;
841
+ }
842
+
843
+ if (eventType === 'session_started') {
844
+ this.agentStatus.set('running');
845
+ if (parsedData?.session_id) {
846
+ this.runningSessionId.set(parsedData.session_id);
847
+ }
848
+ if (parsedData?.initial_goal) {
849
+ this.runningGoal.set(parsedData.initial_goal);
850
+ }
851
+ this.fetchSessions();
852
+
853
+ // Auto-follow: if user has not explicitly pinned a historical session, switch view to new active session
854
+ if (!this.userPinnedSessionId() && parsedData?.session_id) {
855
+ this.selectSession(parsedData.session_id, false);
856
+ }
857
+ return;
858
+ }
859
+
860
+ if (eventType === 'session_ended') {
861
+ const endedId = evtSessionId || this.runningSessionId();
862
+ if (endedId) {
863
+ this.applySessionEndedStatus(endedId, parsedData);
864
+ this.invalidateStatusSignatures();
865
+ this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
866
+ }
867
+ const remaining = this.activeTasks();
868
+ if (remaining.length > 0) {
869
+ this.agentStatus.set('running');
870
+ this.runningSessionId.set(remaining[0].session_id);
871
+ this.runningGoal.set(remaining[0].goal || null);
872
+ } else if (this.pendingQueue().length > 0) {
873
+ const nextPending = this.pendingQueue()[0];
874
+ this.agentStatus.set('running');
875
+ this.runningSessionId.set(nextPending.session_id);
876
+ this.runningGoal.set(nextPending.initial_goal || null);
877
+ } else {
878
+ this.agentStatus.set('idle');
879
+ this.runningSessionId.set(null);
880
+ this.runningGoal.set(null);
881
+ }
882
+ this.isRetrying.set(false);
883
+ this.isPaused.set(false);
884
+ this.fetchSessions();
885
+ this.fetchStatus();
886
+ if (endedId) {
887
+ this.fetchNotes(endedId);
888
+ if (String(endedId) === String(this.currentSessionId() || '')) {
889
+ // The exit final review / run outcome may have landed while the
890
+ // stream was reconnecting: reconcile from the persisted ledger.
891
+ this.fetchChecks(endedId);
892
+ }
893
+ if (
894
+ this.isVideoWindowOpen()
895
+ && this.activeVideoSessionId === endedId
896
+ && this.recordingPlaybackStatus() === 'live'
897
+ ) {
898
+ this.beginRecordingFinalization(endedId);
899
+ }
900
+ }
901
+ return;
902
+ }
903
+
904
+ if (eventType === 'startup_progress') {
905
+ const targetSid = evtSessionId || this.currentSessionId();
906
+ if (targetSid) {
907
+ this.appendStartupProgress(parsedData, targetSid);
908
+ const curId = this.currentSessionId();
909
+ if (
910
+ !this.userPinnedSessionId() &&
911
+ (!curId || String(targetSid).trim().toLowerCase() !== String(curId).trim().toLowerCase())
912
+ ) {
913
+ this.agentStatus.set('running');
914
+ this.runningSessionId.set(targetSid);
915
+ this.selectSession(targetSid, false);
916
+ }
917
+ }
918
+ return;
919
+ }
920
+
921
+ // If the event is for a different session than what user is currently viewing, ignore for logs
922
+ const curId = this.currentSessionId();
923
+ if (evtSessionId && curId && String(evtSessionId).trim().toLowerCase() !== String(curId).trim().toLowerCase()) {
924
+ return;
925
+ }
926
+
927
+ if (eventType === 'llm_retrying') {
928
+ this.isRetrying.set(true);
929
+ const attempt = Number(parsedData.attempt || 0);
930
+ const max = Number(parsedData.max_retries || 0);
931
+ const delay = Number(parsedData.delay || 0);
932
+ const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
933
+ const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
934
+ this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
935
+ if (curId) {
936
+ this.appendLiveLLMRetryTrace(parsedData, curId);
937
+ }
938
+ return;
939
+ }
940
+
941
+ if (eventType === 'task_paused') {
942
+ this.isPaused.set(true);
943
+ this.isRetrying.set(false);
944
+ this.agentStatus.set('paused');
945
+ if (curId) {
946
+ this.runningSessionId.set(curId);
947
+ this.setSessionStatus(curId, 'paused');
948
+ const pauseError = parsedData.error || 'AI call failed';
949
+ this.pausedError.set(pauseError);
950
+ this.appendPausedErrorCard(
951
+ pauseError,
952
+ curId,
953
+ parsedData.timestamp,
954
+ parsedData.step_id,
955
+ parsedData
956
+ );
957
+ }
958
+ return;
959
+ }
960
+
961
+ if (eventType === 'task_resumed') {
962
+ this.isPaused.set(false);
963
+ this.isRetrying.set(false);
964
+ this.pausedError.set(null);
965
+ if (curId && this.runningSessionId() === curId) {
966
+ this.agentStatus.set('running');
967
+ this.setSessionStatus(curId, 'running');
968
+ }
969
+ this.activePauseCardKey = null;
970
+ return;
971
+ }
972
+
973
+ if (eventType === 'step_updated' || eventType === 'step_recorded' || eventType === 'llm_stream') {
974
+ this.isRetrying.set(false);
975
+ }
976
+
977
+ if (eventType === 'llm_stream_reset') {
978
+ const streamExecId = parsedData?.stream_exec_id || parsedData?.stream_execution_id;
979
+ if (streamExecId) {
980
+ for (const key of Array.from(this.pendingStreamChunks.keys())) {
981
+ if (key.startsWith(`${streamExecId}|`) || key === streamExecId) {
982
+ this.pendingStreamChunks.delete(key);
983
+ }
984
+ }
985
+ }
986
+ const resetMessage = parsedData?.message || DEFAULT_STREAM_RESET_MESSAGE;
987
+ this.streamResetEvent.set({
988
+ stream_exec_id: streamExecId,
989
+ stream_execution_id: streamExecId,
990
+ step_id: parsedData?.step_id,
991
+ session_id: parsedData?.session_id,
992
+ action: parsedData?.action || 'discard',
993
+ reason: parsedData?.reason || 'mid_stream_failure',
994
+ category: parsedData?.category,
995
+ error: parsedData?.error,
996
+ message: resetMessage,
997
+ retry_attempt: parsedData?.retry_attempt,
998
+ timestamp: Date.now()
999
+ });
1000
+
1001
+ this.sessionLogs.update((logs) => {
1002
+ let updated = false;
1003
+ const next = logs.map((log) => {
1004
+ if (log.type === 'llm_stream' && log.data?.execution_id === streamExecId) {
1005
+ updated = true;
1006
+ return {
1007
+ ...log,
1008
+ data: {
1009
+ ...log.data,
1010
+ isReset: true,
1011
+ resetMessage
1012
+ }
1013
+ };
1014
+ }
1015
+ return log;
1016
+ });
1017
+ if (!updated && streamExecId) {
1018
+ next.push({
1019
+ type: 'llm_stream',
1020
+ timestamp: new Date().toISOString(),
1021
+ session_id: parsedData?.session_id,
1022
+ data: {
1023
+ execution_id: streamExecId,
1024
+ step_id: parsedData?.step_id,
1025
+ stream_type: 'text',
1026
+ text: '',
1027
+ isCompleted: false,
1028
+ isReset: true,
1029
+ resetMessage
1030
+ }
1031
+ });
1032
+ }
1033
+ return next;
1034
+ });
1035
+ return;
1036
+ }
1037
+
1038
+ if (eventType === 'llm_stream') {
1039
+ this.queueStreamChunk(parsedData);
1040
+ } else {
1041
+ this.sessionLogs.update((logs) => {
1042
+ // Copying every log object on every event is wasted work when no
1043
+ // stream is open; only rewrite entries that actually change.
1044
+ const hasOpenStream = logs.some(
1045
+ (l) => l.type === 'llm_stream' && !l.data.isCompleted
1046
+ );
1047
+ const updatedLogs = !hasOpenStream ? [...logs] : logs.map((l) => {
1048
+ if (l.type === 'llm_stream' && !l.data.isCompleted) {
1049
+ return {
1050
+ ...l,
1051
+ data: { ...l.data, isCompleted: true }
1052
+ };
1053
+ }
1054
+ return l;
1055
+ });
1056
+
1057
+ const evtTime = typeof parsedData?.timestamp === 'number'
1058
+ ? new Date(parsedData.timestamp * 1000).toISOString()
1059
+ : new Date().toISOString();
1060
+
1061
+ const nextLog = {
1062
+ type: eventType,
1063
+ timestamp: evtTime,
1064
+ data: parsedData
1065
+ };
1066
+
1067
+ if (eventType === 'trace_recorded' && parsedData?.trace_id) {
1068
+ const existingTraceIndex = updatedLogs.findIndex((log) =>
1069
+ log.type === 'trace_recorded'
1070
+ && log.data?.trace_id === parsedData.trace_id
1071
+ );
1072
+ if (existingTraceIndex > -1) {
1073
+ const deduplicatedLogs = [...updatedLogs];
1074
+ deduplicatedLogs[existingTraceIndex] = nextLog;
1075
+ return deduplicatedLogs;
1076
+ }
1077
+ }
1078
+
1079
+ return [...updatedLogs, nextLog];
1080
+ });
1081
+ }
1082
+
1083
+ if (eventType === 'trace_recorded' && parsedData) {
1084
+ const trName = parsedData.name || '';
1085
+ if (['save_note', 'read_note', 'update_note', 'append_note', 'list_notes', 'outputter'].includes(trName.toLowerCase())) {
1086
+ const curSessionId = this.currentSessionId();
1087
+ if (curSessionId) {
1088
+ this.fetchNotes(curSessionId);
1089
+ }
1090
+ }
1091
+ }
1092
+ } catch (e) {
1093
+ console.error(`Failed to parse ${eventType} event data:`, e);
1094
+ this.sessionLogs.update((logs) => [
1095
+ ...logs,
1096
+ {
1097
+ type: eventType,
1098
+ timestamp: new Date().toISOString(),
1099
+ data: event.data
1100
+ }
1101
+ ]);
1102
+ }
1103
+ });
1104
+ });
1105
+
1106
+ this.eventSource.onerror = (err) => {
1107
+ console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
1108
+ };
1109
+ });
1110
+ }
1111
+
1112
+ /**
1113
+ * Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most
1114
+ * once per short window (and immediately before any non-stream event so the
1115
+ * relative ordering of stream text and step events is preserved).
1116
+ */
1117
+ private queueStreamChunk(parsedData: any): void {
1118
+ const execId = parsedData.execution_id;
1119
+ const stepId = parsedData.step_id;
1120
+ const sessionId = parsedData.session_id || null;
1121
+ // The emitting agent's trace: lets the aggregator route a concurrent
1122
+ // agent's stream (e.g. the Checker) to its own block instead of the
1123
+ // Operator step that happens to be current.
1124
+ const parentTraceId = parsedData.parent_trace_id || null;
1125
+ const streamType = parsedData.stream_type || 'text';
1126
+ const key = `${execId}|${streamType}`;
1127
+ const chunk = parsedData.chunk !== undefined ? parsedData.chunk : (parsedData.text || '');
1128
+ const pending = this.pendingStreamChunks.get(key);
1129
+ if (pending) {
1130
+ pending.chunk += chunk;
1131
+ if (stepId) pending.stepId = stepId;
1132
+ if (sessionId) pending.sessionId = sessionId;
1133
+ if (parentTraceId) pending.parentTraceId = parentTraceId;
1134
+ } else {
1135
+ this.pendingStreamChunks.set(key, { execId, stepId, sessionId, parentTraceId, streamType, chunk });
1136
+ }
1137
+ if (!this.streamFlushTimer) {
1138
+ const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
1139
+ this.streamFlushTimer = setTimeout(() => this.flushStreamChunks(), delay);
1140
+ }
1141
+ }
1142
+
1143
+ private discardPendingStreamChunks(): void {
1144
+ this.pendingStreamChunks?.clear();
1145
+ if (this.streamFlushTimer) {
1146
+ clearTimeout(this.streamFlushTimer);
1147
+ this.streamFlushTimer = null;
1148
+ }
1149
+ }
1150
+
1151
+ private flushStreamChunks(): void {
1152
+ if (this.streamFlushTimer) {
1153
+ clearTimeout(this.streamFlushTimer);
1154
+ this.streamFlushTimer = null;
1155
+ }
1156
+ if (!this.pendingStreamChunks?.size) return;
1157
+ const batches = Array.from(this.pendingStreamChunks.values());
1158
+ this.pendingStreamChunks.clear();
1159
+
1160
+ this.sessionLogs.update((logs) => {
1161
+ let next = [...logs];
1162
+ for (const batch of batches) {
1163
+ const existingIndex = next.findIndex(
1164
+ (l) => l.type === 'llm_stream' &&
1165
+ l.data.execution_id === batch.execId &&
1166
+ (l.data.stream_type || 'text') === batch.streamType
1167
+ );
1168
+
1169
+ if (existingIndex > -1) {
1170
+ const existingLog = next[existingIndex];
1171
+ next[existingIndex] = {
1172
+ ...existingLog,
1173
+ session_id: batch.sessionId || existingLog.session_id,
1174
+ data: {
1175
+ ...existingLog.data,
1176
+ session_id: batch.sessionId || existingLog.data?.session_id,
1177
+ text: (existingLog.data.text || '') + batch.chunk,
1178
+ step_id: batch.stepId || existingLog.data.step_id,
1179
+ parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null
1180
+ }
1181
+ };
1182
+ } else {
1183
+ // A new execution closes the previous open stream of the same agent
1184
+ // lane only; concurrent agents (Operator vs. Checker) keep theirs.
1185
+ next = next.map((l) => {
1186
+ if (l.type === 'llm_stream' &&
1187
+ (l.data.stream_type || 'text') === batch.streamType &&
1188
+ !l.data.isCompleted &&
1189
+ (l.data.parent_trace_id || null) === (batch.parentTraceId || null)) {
1190
+ return { ...l, data: { ...l.data, isCompleted: true } };
1191
+ }
1192
+ return l;
1193
+ });
1194
+ next.push({
1195
+ type: 'llm_stream',
1196
+ timestamp: new Date().toISOString(),
1197
+ session_id: batch.sessionId || undefined,
1198
+ data: {
1199
+ execution_id: batch.execId,
1200
+ session_id: batch.sessionId || undefined,
1201
+ step_id: batch.stepId,
1202
+ parent_trace_id: batch.parentTraceId || null,
1203
+ text: batch.chunk,
1204
+ stream_type: batch.streamType,
1205
+ isCompleted: false
1206
+ }
1207
+ });
1208
+ }
1209
+ }
1210
+ return next;
1211
+ });
1212
+ }
1213
+
1214
+ /**
1215
+ * Load a fresh persisted snapshot without replacing events already received
1216
+ * from SSE. Snapshot logs are replaceable, while live logs remain append-only;
1217
+ * the stream aggregator merges matching steps and trace IDs within each step.
1218
+ */
1219
+ private backfillSessionSteps(sessionId: string, loadGeneration = this.sessionLoadGeneration): void {
1220
+ const requestId = ++this.sessionSnapshotRequestId;
1221
+ this.pendingSnapshotRequests.add(requestId);
1222
+ this.http.get<StepItemData[]>(`/api/sessions/${sessionId}/steps`).subscribe({
1223
+ next: (steps) => {
1224
+ this.pendingSnapshotRequests.delete(requestId);
1225
+ if (
1226
+ this.currentSessionId() !== sessionId
1227
+ || loadGeneration !== this.sessionLoadGeneration
1228
+ || requestId < this.sessionSnapshotAppliedId
1229
+ ) return;
1230
+ this.sessionSnapshotAppliedId = requestId;
1231
+ const historicalLogs = steps.map((step) => ({
1232
+ type: 'step_updated',
1233
+ session_id: sessionId,
1234
+ timestamp: new Date((step.timestamp || Date.now() / 1000) * 1000).toISOString(),
1235
+ data: step,
1236
+ history_snapshot: true
1237
+ }));
1238
+
1239
+ this.sessionLogs.update((logs) => [
1240
+ ...historicalLogs,
1241
+ ...logs.filter((log) => !log.history_snapshot)
1242
+ ]);
1243
+ this.isSessionContentLoading.set(false);
1244
+ },
1245
+ error: (err) => {
1246
+ this.pendingSnapshotRequests.delete(requestId);
1247
+ console.error('Failed to backfill session steps:', err);
1248
+ if (
1249
+ this.currentSessionId() === sessionId
1250
+ && loadGeneration === this.sessionLoadGeneration
1251
+ && this.pendingSnapshotRequests.size === 0
1252
+ ) {
1253
+ this.isSessionContentLoading.set(false);
1254
+ }
1255
+ }
1256
+ });
1257
+
1258
+ this.http.get<StartupProgressEvent[]>(`/api/sessions/${sessionId}/startup_progress`).subscribe({
1259
+ next: (events) => {
1260
+ if (Array.isArray(events) && events.length > 0) {
1261
+ this.startupProgressBySession.update((current) => {
1262
+ const existing = current[sessionId] || [];
1263
+ const merged = [...existing];
1264
+ for (const ev of events) {
1265
+ if (!merged.some((m) => m.stage === ev.stage)) {
1266
+ merged.push(ev);
1267
+ }
1268
+ }
1269
+ return {
1270
+ ...current,
1271
+ [sessionId]: merged
1272
+ };
1273
+ });
1274
+ }
1275
+ },
1276
+ error: () => {}
1277
+ });
1278
+ }
1279
+
1280
+ private restoreSessionsCache(): void {
1281
+ try {
1282
+ const cached = localStorage.getItem(SESSION_CACHE_KEY);
1283
+ if (!cached) return;
1284
+ const sessions = JSON.parse(cached);
1285
+ if (Array.isArray(sessions)) {
1286
+ this.lastPersistedSessionsJson = cached;
1287
+ this.rawSessions.set(sessions);
1288
+ }
1289
+ } catch {
1290
+ this.clearSessionsCache();
1291
+ }
1292
+ }
1293
+
1294
+ private persistSessionsCache(sessions: Session[]): void {
1295
+ try {
1296
+ const serialized = JSON.stringify(sessions);
1297
+ if (serialized === this.lastPersistedSessionsJson) return;
1298
+ this.lastPersistedSessionsJson = serialized;
1299
+ localStorage.setItem(SESSION_CACHE_KEY, serialized);
1300
+ } catch {
1301
+ // Storage can be unavailable in private browsing or embedded contexts.
1302
+ }
1303
+ }
1304
+
1305
+ private clearSessionsCache(): void {
1306
+ this.lastPersistedSessionsJson = null;
1307
+ try {
1308
+ localStorage.removeItem(SESSION_CACHE_KEY);
1309
+ } catch {
1310
+ // No-op when browser storage is unavailable.
1311
+ }
1312
+ }
1313
+
1314
+ /**
1315
+ * Convert the dedicated live retry event into the same trace shape used by
1316
+ * history APIs. This keeps live streaming and post-refresh rendering on one
1317
+ * data contract instead of maintaining a separate retry UI path.
1318
+ */
1319
+ private appendLiveLLMRetryTrace(data: any, sessionId: string): void {
1320
+ const timestampSeconds = typeof data?.timestamp === 'number'
1321
+ ? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
1322
+ : Date.now() / 1000;
1323
+ const traceId = data?.trace_id
1324
+ || `llm-retry-live-${data?.request_id || 'unknown'}-${timestampSeconds}`;
1325
+ const payload = {
1326
+ error: data?.error,
1327
+ delay: data?.delay,
1328
+ attempt: data?.attempt,
1329
+ max_retries: data?.max_retries,
1330
+ provider: data?.provider,
1331
+ source: data?.source,
1332
+ recoverable: data?.recoverable,
1333
+ request_id: data?.request_id,
1334
+ scheduled_at: data?.scheduled_at ?? timestampSeconds
1335
+ };
1336
+ const retryTrace = {
1337
+ trace_id: traceId,
1338
+ session_id: sessionId,
1339
+ step_id: data?.step_id || null,
1340
+ type: 'llm_call',
1341
+ name: 'llm_retry',
1342
+ timestamp: timestampSeconds,
1343
+ status: 'retrying',
1344
+ payload
1345
+ };
1346
+
1347
+ this.sessionLogs.update((logs) => {
1348
+ const existingIndex = logs.findIndex((log) =>
1349
+ log.type === 'trace_recorded'
1350
+ && (
1351
+ log.data?.trace_id === traceId
1352
+ || (
1353
+ data?.request_id
1354
+ && log.data?.payload?.request_id === data.request_id
1355
+ && Number(log.data?.payload?.scheduled_at) === Number(payload.scheduled_at)
1356
+ )
1357
+ )
1358
+ );
1359
+ const retryLog = {
1360
+ type: 'trace_recorded',
1361
+ session_id: sessionId,
1362
+ timestamp: new Date(timestampSeconds * 1000).toISOString(),
1363
+ data: retryTrace
1364
+ };
1365
+ if (existingIndex < 0) return [...logs, retryLog];
1366
+
1367
+ const updatedLogs = [...logs];
1368
+ updatedLogs[existingIndex] = retryLog;
1369
+ return updatedLogs;
1370
+ });
1371
+ }
1372
+
1373
+ /**
1374
+ * Preserve pause failures in the normal failed-LLM card stream. New runners
1375
+ * emit a persisted failed trace first; this fallback also supports older
1376
+ * runners whose task_paused event only carried an error string.
1377
+ */
1378
+ private appendPausedErrorCard(
1379
+ error: unknown,
1380
+ sessionId: string,
1381
+ timestamp?: number | string,
1382
+ stepId?: string | null,
1383
+ details?: any
1384
+ ): void {
1385
+ const errorText = typeof error === 'string' ? error : JSON.stringify(error);
1386
+ const pauseKey = `${sessionId}:${errorText}`;
1387
+ if (this.activePauseCardKey === pauseKey) return;
1388
+
1389
+ this.sessionLogs.update((logs) => {
1390
+ const alreadyRecorded = logs.some((log) => {
1391
+ const traces = log?.type === 'trace_recorded'
1392
+ ? [log.data]
1393
+ : (Array.isArray(log?.data?.generic_tools) ? log.data.generic_tools : []);
1394
+ return traces.some((trace: any) =>
1395
+ trace?.type === 'llm_call'
1396
+ && trace?.status === 'failed'
1397
+ && String(trace?.payload?.error ?? trace?.error ?? '') === errorText
1398
+ );
1399
+ });
1400
+ if (alreadyRecorded) return logs;
1401
+
1402
+ const latestStepId = stepId || [...logs].reverse().find((log) =>
1403
+ (log.type === 'step_updated' || log.type === 'step_recorded') && log.data?.step_id
1404
+ )?.data?.step_id || null;
1405
+ const timestampMs = typeof timestamp === 'number'
1406
+ ? (timestamp < 1e11 ? timestamp * 1000 : timestamp)
1407
+ : (timestamp ? new Date(timestamp).getTime() : Date.now());
1408
+
1409
+ return [
1410
+ ...logs,
1411
+ {
1412
+ type: 'trace_recorded',
1413
+ session_id: sessionId,
1414
+ timestamp: new Date(timestampMs).toISOString(),
1415
+ data: {
1416
+ trace_id: `task-paused-${sessionId}-${timestampMs}`,
1417
+ session_id: sessionId,
1418
+ step_id: latestStepId,
1419
+ type: 'llm_call',
1420
+ name: 'llm_pause',
1421
+ status: 'failed',
1422
+ timestamp: timestampMs / 1000,
1423
+ payload: {
1424
+ error: errorText,
1425
+ pause: true,
1426
+ request_id: details?.request_id,
1427
+ provider: details?.provider,
1428
+ waited_seconds: details?.waited_seconds,
1429
+ retries: Array.isArray(details?.retries) ? details.retries : []
1430
+ }
1431
+ }
1432
+ }
1433
+ ];
1434
+ });
1435
+ this.activePauseCardKey = pauseKey;
1436
+ }
1437
+
1438
+ private pollCounter: number = 0;
1439
+
1440
+ private setSessionStatus(sessionId: string | null, status: Session['status']): void {
1441
+ if (!sessionId) return;
1442
+
1443
+ this.invalidateStatusSignatures();
1444
+ this.rawSessions.update((sessions) => sessions.map((session) =>
1445
+ session.session_id === sessionId ? { ...session, status } : session
1446
+ ));
1447
+ this.pendingQueue.update((sessions) => sessions.map((session) =>
1448
+ session.session_id === sessionId ? { ...session, status } : session
1449
+ ));
1450
+ }
1451
+
1452
+ private applySessionEndedStatus(sessionId: unknown, data: any): void {
1453
+ if (!sessionId) return;
1454
+
1455
+ const reportedStatus = String(data?.status || '').toLowerCase();
1456
+ const status: Session['status'] | null = data?.was_stopped_manually || reportedStatus === 'cancelled'
1457
+ ? 'cancelled'
1458
+ : reportedStatus === 'failed'
1459
+ ? 'failed'
1460
+ : reportedStatus === 'completed' || reportedStatus === 'success'
1461
+ ? 'completed'
1462
+ : null;
1463
+
1464
+ if (status) {
1465
+ this.activeSessionTracking.delete(String(sessionId));
1466
+ this.setSessionStatus(String(sessionId), status);
1467
+ this.invalidateStatusSignatures();
1468
+ this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
1469
+ }
1470
+ }
1471
+
1472
+ /**
1473
+ * Start periodic status polling from the backend
1474
+ */
1475
+ private startStatusPolling(): void {
1476
+ this.fetchStatus();
1477
+ // The interval runs outside the Angular zone so a poll tick alone never
1478
+ // schedules change detection; only actual signal updates do.
1479
+ this.zone.runOutsideAngular(() => {
1480
+ this.statusInterval = setInterval(() => {
1481
+ if (typeof document !== 'undefined' && document.hidden) {
1482
+ return; // Paused while the tab is in the background; resumed by visibilitychange.
1483
+ }
1484
+ this.fetchStatus();
1485
+ this.pollCounter++;
1486
+ // Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
1487
+ if (this.pollCounter % 3 === 0) {
1488
+ this.fetchSessions();
1489
+ }
1490
+ }, 2000);
1491
+ });
1492
+ }
1493
+
1494
+ /**
1495
+ * Optimistic local mutations of queue/active state must force the next poll
1496
+ * to re-apply backend data even when its payload has not changed.
1497
+ */
1498
+ private invalidateStatusSignatures(): void {
1499
+ this.lastQueueSignature = null;
1500
+ this.lastActiveTasksSignature = null;
1501
+ }
1502
+
1503
+ /**
1504
+ * Fetch current agent runner process status
1505
+ */
1506
+ public fetchStatus(): void {
1507
+ this.http.get<any>('/api/status').subscribe({
1508
+ next: (data) => {
1509
+ if (data && data.status) {
1510
+ const oldStatus = this.agentStatus();
1511
+ const oldRunningSessionId = this.runningSessionId();
1512
+ const isActive = data.status === 'running' || data.status === 'paused';
1513
+ this.agentStatus.set(data.status);
1514
+ this.runningSessionId.set(data.session_id || null);
1515
+ this.runningGoal.set(data.goal || null);
1516
+ if (data.model_info) {
1517
+ this.activeModel.set(data.model_info);
1518
+ }
1519
+ this.isPaused.set(data.status === 'paused');
1520
+
1521
+ if (data.status === 'paused') {
1522
+ this.isRetrying.set(false);
1523
+ const pauseError = data.paused_error
1524
+ || this.pausedError()
1525
+ || 'AI model request failed. The task is paused.';
1526
+ this.pausedError.set(pauseError);
1527
+ if (data.session_id && this.currentSessionId() === data.session_id) {
1528
+ this.appendPausedErrorCard(pauseError, data.session_id);
1529
+ }
1530
+ } else {
1531
+ this.pausedError.set(null);
1532
+ this.activePauseCardKey = null;
1533
+ }
1534
+
1535
+ // Skip signal updates when backend payloads did not change: setting a
1536
+ // fresh array reference every 2s poll would force the whole sessions
1537
+ // computed chain to re-derive for identical data.
1538
+ const queueSignature = JSON.stringify(data.queue || []);
1539
+ if (queueSignature !== this.lastQueueSignature) {
1540
+ this.lastQueueSignature = queueSignature;
1541
+ // Map backend pending queue to real Session / TaskQueueItem objects
1542
+ const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
1543
+ if (typeof item === 'object' && item !== null) {
1544
+ return {
1545
+ session_id: item.session_id || `pending-task-${index}`,
1546
+ initial_goal: item.goal || '',
1547
+ start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
1548
+ status: item.status || 'pending',
1549
+ device_serial: item.device_serial || item.device_id || null
1550
+ };
1551
+ }
1552
+ return {
1553
+ session_id: `task-queued-${index}`,
1554
+ initial_goal: String(item),
1555
+ start_time: (Date.now() / 1000) + index,
1556
+ status: 'pending'
1557
+ };
1558
+ });
1559
+ this.pendingQueue.set(pending);
1560
+ }
1561
+ const activeTasksSignature = JSON.stringify(data.active_tasks || []);
1562
+ if (activeTasksSignature !== this.lastActiveTasksSignature) {
1563
+ this.lastActiveTasksSignature = activeTasksSignature;
1564
+ this.activeTasks.set(data.active_tasks || []);
1565
+ }
1566
+
1567
+ if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
1568
+ this.fetchSessions();
1569
+ }
1570
+
1571
+ if (
1572
+ !isActive
1573
+ && (oldStatus === 'running' || oldStatus === 'paused')
1574
+ && oldRunningSessionId
1575
+ && this.isVideoWindowOpen()
1576
+ && this.activeVideoSessionId === oldRunningSessionId
1577
+ && this.recordingPlaybackStatus() === 'live'
1578
+ ) {
1579
+ this.beginRecordingFinalization(oldRunningSessionId);
1580
+ }
1581
+
1582
+ // Auto-select the active session if user has not explicitly pinned a historical session
1583
+ if (isActive && data.session_id) {
1584
+ const currentId = this.currentSessionId();
1585
+ if (!currentId || (!this.userPinnedSessionId() && currentId !== data.session_id)) {
1586
+ this.selectSession(data.session_id, false);
1587
+ }
1588
+ }
1589
+ }
1590
+ },
1591
+ error: (err) => {
1592
+ console.error('Failed to fetch status from backend:', err);
1593
+ this.agentStatus.set('offline');
1594
+ this.runningSessionId.set(null);
1595
+ this.runningGoal.set(null);
1596
+ }
1597
+ });
1598
+ }
1599
+
1600
+ /**
1601
+ * Clean up event source connection and timers on service destruction
1602
+ */
1603
+ public destroy(): void {
1604
+ if (this.statusInterval) {
1605
+ clearInterval(this.statusInterval);
1606
+ this.statusInterval = null;
1607
+ }
1608
+ if (this.eventSource) {
1609
+ this.eventSource.close();
1610
+ this.eventSource = null;
1611
+ }
1612
+ this.discardPendingStreamChunks();
1613
+ if (typeof document !== 'undefined') {
1614
+ document.removeEventListener('visibilitychange', this.onVisibilityChange);
1615
+ }
1616
+ this.cancelVideoRetry();
1617
+ }
1618
+
1619
+ /**
1620
+ * Fetch notes for a specific session
1621
+ */
1622
+ /**
1623
+ * Backfill the Checker's attempts and run outcome from the persisted verdict
1624
+ * ledger (`/api/sessions/{id}/checks`) as synthetic `checker_event` logs, so
1625
+ * historical sessions show the same timeline blocks as live ones. Live
1626
+ * events for attempts already known are merged by attempt id in the
1627
+ * aggregator, so re-fetching is idempotent.
1628
+ */
1629
+ public fetchChecks(sessionId: string): void {
1630
+ if (!sessionId) return;
1631
+ this.http.get<{ records?: any[]; streams?: PersistedCheckerStream[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
1632
+ next: (res) => {
1633
+ if (this.currentSessionId() !== sessionId) return;
1634
+ const snapshot = this.buildCheckerSnapshotLogs(
1635
+ sessionId, res?.records || [], res?.run_outcome || null, res?.streams || []
1636
+ );
1637
+ this.sessionLogs.update((logs) => [
1638
+ ...logs.filter((log) => !log.checks_snapshot),
1639
+ ...snapshot
1640
+ ]);
1641
+ },
1642
+ error: (err) => {
1643
+ console.error(`Failed to fetch checks for session ${sessionId}:`, err);
1644
+ }
1645
+ });
1646
+ }
1647
+
1648
+ /**
1649
+ * `streams` are the attempts' persisted transcripts (what the Checker
1650
+ * streamed while reaching its verdict): they become the attempt's
1651
+ * `stream_segments`, the same shape the live `llm_stream` chunks build, so
1652
+ * a reopened session interleaves Thought/Work text with tool rows exactly
1653
+ * like the live view did.
1654
+ */
1655
+ private buildCheckerSnapshotLogs(
1656
+ sessionId: string,
1657
+ records: any[],
1658
+ runOutcome: any,
1659
+ streams: PersistedCheckerStream[] = []
1660
+ ): any[] {
1661
+ const byAttempt = new Map<string, any>();
1662
+ const segmentsByAttempt = new Map<string, StreamSegment[]>();
1663
+ for (const stream of streams || []) {
1664
+ if (!stream || !stream.attempt_id) continue;
1665
+ const segments = persistedStreamToSegments(stream);
1666
+ if (segments.length > 0) segmentsByAttempt.set(String(stream.attempt_id), segments);
1667
+ }
1668
+ for (const rec of records) {
1669
+ if (!rec || !rec.attempt_id) continue;
1670
+ const attemptId = String(rec.attempt_id);
1671
+ const checkpointId = String(rec.checkpoint_id || '');
1672
+ let attempt = byAttempt.get(attemptId);
1673
+ if (!attempt) {
1674
+ attempt = {
1675
+ event: 'attempt_finished',
1676
+ phase: checkpointId === 'final' ? 'final' : 'checkpoint',
1677
+ attempt_id: attemptId,
1678
+ checkpoint_id: checkpointId,
1679
+ subgoal_text: String(rec.subgoal_text || (checkpointId === 'final'
1680
+ ? "Final review against the user's original goal"
1681
+ : `Subgoal ${checkpointId.slice(0, 8)}`)),
1682
+ anchor_step_id: rec.anchor_step_id ?? null,
1683
+ trace_id: rec.trace_id ?? null,
1684
+ status: 'done',
1685
+ verdicts: [],
1686
+ findings: [],
1687
+ ts: typeof rec.ts === 'number' ? rec.ts : undefined,
1688
+ session_id: sessionId
1689
+ };
1690
+ byAttempt.set(attemptId, attempt);
1691
+ }
1692
+ if (typeof rec.ts === 'number') {
1693
+ attempt.ts = attempt.ts === undefined ? rec.ts : Math.min(attempt.ts, rec.ts);
1694
+ }
1695
+ if (!attempt.trace_id && rec.trace_id) {
1696
+ attempt.trace_id = rec.trace_id;
1697
+ }
1698
+ attempt.verdicts.push({
1699
+ item_text: String(rec.item_text || ''),
1700
+ kind: String(rec.kind || ''),
1701
+ status: String(rec.status || ''),
1702
+ evidence: String(rec.evidence || ''),
1703
+ suggestion: rec.suggestion ? String(rec.suggestion) : '',
1704
+ when: rec.when ? String(rec.when) : undefined
1705
+ });
1706
+ }
1707
+
1708
+ const logs: any[] = [];
1709
+ for (const attempt of byAttempt.values()) {
1710
+ const statuses = new Set(attempt.verdicts.map((v: any) => v.status));
1711
+ if (statuses.size === 1) {
1712
+ const only = [...statuses][0];
1713
+ if (only === 'superseded' || only === 'unchecked') attempt.status = only;
1714
+ }
1715
+ const ts = attempt.ts ?? Date.now() / 1000;
1716
+ attempt.timestamp = ts;
1717
+ const segments = segmentsByAttempt.get(attempt.attempt_id);
1718
+ if (segments) attempt.stream_segments = segments;
1719
+ logs.push({
1720
+ type: 'checker_event',
1721
+ session_id: sessionId,
1722
+ timestamp: new Date(ts * 1000).toISOString(),
1723
+ checks_snapshot: true,
1724
+ data: attempt
1725
+ });
1726
+ }
1727
+ if (runOutcome && typeof runOutcome === 'object') {
1728
+ const lastTs = logs.length > 0
1729
+ ? Math.max(...logs.map((l) => new Date(l.timestamp).getTime() / 1000))
1730
+ : Date.now() / 1000;
1731
+ const ts = lastTs + 0.001;
1732
+ logs.push({
1733
+ type: 'checker_event',
1734
+ session_id: sessionId,
1735
+ timestamp: new Date(ts * 1000).toISOString(),
1736
+ checks_snapshot: true,
1737
+ data: { event: 'run_outcome', phase: 'outcome', ...runOutcome, ts, timestamp: ts, session_id: sessionId }
1738
+ });
1739
+ }
1740
+ return logs;
1741
+ }
1742
+
1743
+ public fetchNotes(sessionId: string): void {
1744
+ if (!sessionId) {
1745
+ this.currentNotes.set({});
1746
+ return;
1747
+ }
1748
+ this.http.get<any>(`/api/sessions/${sessionId}/notes`).subscribe({
1749
+ next: (res) => {
1750
+ if (res && res.notes) {
1751
+ this.currentNotes.set(res.notes);
1752
+
1753
+ // Default to task_plan.md if available, otherwise first note
1754
+ const keys = Object.keys(res.notes);
1755
+ if (keys.length > 0) {
1756
+ const currentSelected = this.selectedNoteKey();
1757
+ if (!keys.includes(currentSelected)) {
1758
+ if (keys.includes('task_plan.md')) {
1759
+ this.selectedNoteKey.set('task_plan.md');
1760
+ } else {
1761
+ this.selectedNoteKey.set(keys[0]);
1762
+ }
1763
+ }
1764
+ }
1765
+ }
1766
+ },
1767
+ error: (err) => {
1768
+ console.error(`Failed to fetch notes for session ${sessionId}:`, err);
1769
+ }
1770
+ });
1771
+ }
1772
+
1773
+ /**
1774
+ * Open the video player for a given session or the current session
1775
+ */
1776
+ public openVideoPlayer(
1777
+ sessionId?: string,
1778
+ videoUrl?: string,
1779
+ title?: string,
1780
+ seekSeconds?: number,
1781
+ stepIndex?: number
1782
+ ): void {
1783
+ const targetSessionId = sessionId || this.currentSessionId();
1784
+ const session = this.sessions().find(s => s.session_id === targetSessionId);
1785
+ const targetUrl = videoUrl || session?.video_url || null;
1786
+ const goalTitle = title || session?.initial_goal || (targetSessionId ? `Task: ${targetSessionId.slice(0, 8)}...` : 'Screen Recording');
1787
+
1788
+ this.activeVideoTitle.set(goalTitle);
1789
+ this.isVideoWindowOpen.set(true);
1790
+ this.isVideoMinimized.set(false);
1791
+ this.activeVideoSessionId = targetSessionId;
1792
+ this.cancelVideoRetry();
1793
+ this.videoRequestGeneration++;
1794
+ this.shouldAutoplayVideo.set(false);
1795
+ this.recordingPlaybackMessage.set('');
1796
+ this.activeVideoSegments.set([]);
1797
+ if (Number.isFinite(seekSeconds)) this.requestVideoSeek(Number(seekSeconds));
1798
+ if (Number.isFinite(stepIndex)) {
1799
+ this.playerMode?.set('steps');
1800
+ this.requestStepSeek(Number(stepIndex));
1801
+ }
1802
+ if (!targetSessionId) {
1803
+ this.activeVideoUrl.set(null);
1804
+ this.isVideoLoading.set(false);
1805
+ this.recordingPlaybackStatus.set('unavailable');
1806
+ return;
1807
+ }
1808
+
1809
+ if (session?.status === 'running' || session?.status === 'paused') {
1810
+ this.activeVideoUrl.set(null);
1811
+ this.isVideoLoading.set(false);
1812
+ this.recordingPlaybackStatus.set('live');
1813
+ return;
1814
+ }
1815
+
1816
+ this.activeVideoUrl.set(targetUrl);
1817
+ this.shouldAutoplayVideo.set(true);
1818
+ this.videoWaitStartedAt = Date.now();
1819
+ this.isVideoLoading.set(true);
1820
+ this.recordingPlaybackStatus.set('processing');
1821
+ this.recordingPlaybackMessage.set('Loading screen recording...');
1822
+ if (targetUrl) {
1823
+ this.playerMode?.set('video');
1824
+ } else if (this.hasCurrentSessionStepFrames?.()) {
1825
+ this.playerMode?.set('steps');
1826
+ } else {
1827
+ this.playerMode?.set('video');
1828
+ }
1829
+ this.requestSessionVideo(targetSessionId, this.videoRequestGeneration);
1830
+ }
1831
+
1832
+ private appendStartupProgress(data: any, sessionId: string): void {
1833
+ if (!data?.stage || !data?.message) return;
1834
+
1835
+ const timestamp = typeof data.timestamp === 'number'
1836
+ ? (data.timestamp > 1e11 ? data.timestamp / 1000 : data.timestamp)
1837
+ : Date.now() / 1000;
1838
+ const event: StartupProgressEvent = {
1839
+ session_id: String(data.session_id || sessionId),
1840
+ stage: String(data.stage),
1841
+ message: String(data.message),
1842
+ timestamp
1843
+ };
1844
+
1845
+ const rawKey = String(data.session_id || sessionId).trim();
1846
+ this.startupProgressBySession.update((allEvents) => {
1847
+ const current = [...(allEvents[rawKey] || allEvents[sessionId] || [])];
1848
+ const existingIndex = current.findIndex((item) => item.stage === event.stage);
1849
+ if (existingIndex >= 0) {
1850
+ current[existingIndex] = event;
1851
+ } else {
1852
+ current.push(event);
1853
+ }
1854
+ current.sort((a, b) => a.timestamp - b.timestamp);
1855
+ const updated = { ...allEvents, [rawKey]: current };
1856
+ if (sessionId && sessionId !== rawKey) {
1857
+ updated[sessionId] = current;
1858
+ }
1859
+ return updated;
1860
+ });
1861
+ }
1862
+
1863
+ private beginRecordingFinalization(sessionId: string): void {
1864
+ if (this.activeVideoSessionId !== sessionId) return;
1865
+ this.cancelVideoRetry();
1866
+ this.videoRequestGeneration++;
1867
+ this.videoWaitStartedAt = Date.now();
1868
+ this.activeVideoUrl.set(null);
1869
+ this.activeVideoSegments.set([]);
1870
+ this.shouldAutoplayVideo.set(true);
1871
+ this.isVideoLoading.set(true);
1872
+ this.recordingPlaybackStatus.set('processing');
1873
+ this.recordingPlaybackMessage.set('Finalizing screen recording...');
1874
+ this.requestSessionVideo(sessionId, this.videoRequestGeneration);
1875
+ }
1876
+
1877
+ private requestSessionVideo(sessionId: string, generation: number): void {
1878
+ this.http.get<SessionVideoResponse>(`/api/sessions/${sessionId}/video`).subscribe({
1879
+ next: (res) => {
1880
+ if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
1881
+ return;
1882
+ }
1883
+ const status = res.status || (res.has_video && res.video_url ? 'ready' : 'unavailable');
1884
+ if (status === 'ready' && res.video_url) {
1885
+ this.cancelVideoRetry();
1886
+ this.isVideoLoading.set(false);
1887
+ this.recordingPlaybackStatus.set('ready');
1888
+ this.recordingPlaybackMessage.set('');
1889
+ this.activeVideoUrl.set(res.video_url);
1890
+ this.activeVideoSegments.set(res.video_segments || []);
1891
+ this.playerMode?.set('video');
1892
+ this.rawSessions.update((list) =>
1893
+ list.map((s) => s.session_id === sessionId
1894
+ ? { ...s, video_url: res.video_url || undefined, recording_status: 'ready' }
1895
+ : s)
1896
+ );
1897
+ return;
1898
+ }
1899
+ if (status === 'processing') {
1900
+ this.activeVideoUrl.set(null);
1901
+ this.activeVideoSegments.set([]);
1902
+ this.isVideoLoading.set(true);
1903
+ this.recordingPlaybackStatus.set('processing');
1904
+ this.recordingPlaybackMessage.set('Finalizing screen recording...');
1905
+ this.scheduleVideoRetry(sessionId, generation, res.retry_after_ms);
1906
+ return;
1907
+ }
1908
+
1909
+ this.cancelVideoRetry();
1910
+ this.isVideoLoading.set(false);
1911
+ this.activeVideoUrl.set(null);
1912
+ this.activeVideoSegments.set([]);
1913
+ this.recordingPlaybackStatus.set(status === 'failed' ? 'failed' : 'unavailable');
1914
+ this.recordingPlaybackMessage.set(
1915
+ res.message || (status === 'failed'
1916
+ ? 'Recording finalization failed.'
1917
+ : 'No screen recording is available for this task.')
1918
+ );
1919
+ if (this.hasCurrentSessionStepFrames?.()) {
1920
+ this.playerMode?.set('steps');
1921
+ }
1922
+ },
1923
+ error: () => {
1924
+ if (generation !== this.videoRequestGeneration || sessionId !== this.activeVideoSessionId) {
1925
+ return;
1926
+ }
1927
+ if (this.recordingPlaybackStatus() === 'processing') {
1928
+ this.scheduleVideoRetry(sessionId, generation, 1000);
1929
+ return;
1930
+ }
1931
+ this.isVideoLoading.set(false);
1932
+ this.recordingPlaybackStatus.set('failed');
1933
+ this.recordingPlaybackMessage.set('Unable to load the screen recording.');
1934
+ if (this.hasCurrentSessionStepFrames?.()) {
1935
+ this.playerMode?.set('steps');
1936
+ }
1937
+ }
1938
+ });
1939
+ }
1940
+
1941
+ private scheduleVideoRetry(sessionId: string, generation: number, retryAfterMs = 1000): void {
1942
+ this.cancelVideoRetry();
1943
+ if (Date.now() - this.videoWaitStartedAt > 120_000) {
1944
+ this.isVideoLoading.set(false);
1945
+ this.recordingPlaybackStatus.set('failed');
1946
+ this.recordingPlaybackMessage.set('Recording finalization timed out. You can retry.');
1947
+ return;
1948
+ }
1949
+ const delay = Math.max(500, Math.min(3000, retryAfterMs));
1950
+ this.videoRetryTimer = setTimeout(() => {
1951
+ this.videoRetryTimer = null;
1952
+ this.requestSessionVideo(sessionId, generation);
1953
+ }, delay);
1954
+ }
1955
+
1956
+ private cancelVideoRetry(): void {
1957
+ if (this.videoRetryTimer) {
1958
+ clearTimeout(this.videoRetryTimer);
1959
+ this.videoRetryTimer = null;
1960
+ }
1961
+ }
1962
+
1963
+ private refreshActiveRecording(autoplay: boolean): void {
1964
+ const sessionId = this.activeVideoSessionId;
1965
+ if (!sessionId) return;
1966
+ this.cancelVideoRetry();
1967
+ this.videoRequestGeneration++;
1968
+ if (autoplay) this.shouldAutoplayVideo.set(true);
1969
+ this.requestSessionVideo(sessionId, this.videoRequestGeneration);
1970
+ }
1971
+
1972
+ public retryVideoRecording(): void {
1973
+ const sessionId = this.activeVideoSessionId;
1974
+ if (!sessionId) return;
1975
+ this.beginRecordingFinalization(sessionId);
1976
+ }
1977
+
1978
+ public consumeVideoAutoplay(): boolean {
1979
+ const shouldAutoplay = this.shouldAutoplayVideo();
1980
+ this.shouldAutoplayVideo.set(false);
1981
+ return shouldAutoplay;
1982
+ }
1983
+
1984
+ public requestVideoSeek(seconds: number): void {
1985
+ if (!Number.isFinite(seconds)) return;
1986
+ this.videoSeekRequest.set({
1987
+ seconds: Math.max(0, seconds),
1988
+ requestId: ++this.videoSeekRequestId
1989
+ });
1990
+ }
1991
+
1992
+ /**
1993
+ * Toggle the video player for the current session
1994
+ */
1995
+ public toggleVideoPlayer(): void {
1996
+ if (this.isVideoWindowOpen()) {
1997
+ this.isVideoWindowOpen.set(false);
1998
+ } else {
1999
+ this.openVideoPlayer();
2000
+ }
2001
+ }
2002
+
2003
+ /**
2004
+ * Close the video player
2005
+ */
2006
+ public closeVideoPlayer(): void {
2007
+ this.isVideoWindowOpen.set(false);
2008
+ this.cancelVideoRetry();
2009
+ this.videoRequestGeneration++;
2010
+ this.activeVideoSessionId = null;
2011
+ }
2012
+ }