mobster-cli 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (1009) hide show
  1. mobile_agent/__init__.py +4 -0
  2. mobile_agent/__main__.py +598 -0
  3. mobile_agent/accounts/__init__.py +1 -0
  4. mobile_agent/accounts/context.py +80 -0
  5. mobile_agent/accounts/mcp.py +55 -0
  6. mobile_agent/accounts/pacing.py +318 -0
  7. mobile_agent/accounts/register.py +78 -0
  8. mobile_agent/accounts/routes.py +212 -0
  9. mobile_agent/accounts/routing.py +69 -0
  10. mobile_agent/accounts/screen.py +273 -0
  11. mobile_agent/accounts/stats.py +54 -0
  12. mobile_agent/accounts/store.py +394 -0
  13. mobile_agent/agent_hooks.py +123 -0
  14. mobile_agent/ai_plans/__init__.py +7 -0
  15. mobile_agent/ai_plans/agents.py +96 -0
  16. mobile_agent/ai_plans/claude_code.py +208 -0
  17. mobile_agent/ai_plans/cli.py +400 -0
  18. mobile_agent/ai_plans/codex.py +258 -0
  19. mobile_agent/ai_plans/compat_chat.py +307 -0
  20. mobile_agent/ai_plans/device_login.py +164 -0
  21. mobile_agent/ai_plans/draft_client.py +68 -0
  22. mobile_agent/ai_plans/gemini.py +137 -0
  23. mobile_agent/ai_plans/instructions.py +65 -0
  24. mobile_agent/ai_plans/mcp_http.py +160 -0
  25. mobile_agent/ai_plans/phone_tools.py +892 -0
  26. mobile_agent/ai_plans/providers.py +235 -0
  27. mobile_agent/ai_plans/routes.py +160 -0
  28. mobile_agent/ai_plans/runner.py +400 -0
  29. mobile_agent/ai_plans/vault.py +114 -0
  30. mobile_agent/alerts.py +337 -0
  31. mobile_agent/android/__init__.py +18 -0
  32. mobile_agent/android/adb.py +268 -0
  33. mobile_agent/android/conditions.py +128 -0
  34. mobile_agent/android/doctor.py +54 -0
  35. mobile_agent/android/driver.py +137 -0
  36. mobile_agent/android/emulator.py +216 -0
  37. mobile_agent/android/logs.py +85 -0
  38. mobile_agent/android/manager.py +225 -0
  39. mobile_agent/android/records.py +31 -0
  40. mobile_agent/android/tree.py +96 -0
  41. mobile_agent/answer_shapes.py +230 -0
  42. mobile_agent/api_devices.py +333 -0
  43. mobile_agent/api_errors.py +17 -0
  44. mobile_agent/api_get.py +249 -0
  45. mobile_agent/api_http.py +467 -0
  46. mobile_agent/api_post.py +453 -0
  47. mobile_agent/api_routes.py +173 -0
  48. mobile_agent/app_api/__init__.py +4 -0
  49. mobile_agent/app_api/cli.py +151 -0
  50. mobile_agent/app_api/endpoints.py +193 -0
  51. mobile_agent/app_api/mcp.py +54 -0
  52. mobile_agent/app_api/openapi.py +146 -0
  53. mobile_agent/app_api/reading.py +172 -0
  54. mobile_agent/app_api/register.py +10 -0
  55. mobile_agent/app_api/screen.py +216 -0
  56. mobile_agent/app_api/service.py +382 -0
  57. mobile_agent/app_api/sessions.py +404 -0
  58. mobile_agent/app_api/tokens.py +167 -0
  59. mobile_agent/app_choice.py +131 -0
  60. mobile_agent/app_icons.py +183 -0
  61. mobile_agent/app_killer.py +69 -0
  62. mobile_agent/artifacts.py +606 -0
  63. mobile_agent/attach/__init__.py +13 -0
  64. mobile_agent/attach/cli.py +46 -0
  65. mobile_agent/attach/flutter.py +28 -0
  66. mobile_agent/attach/score.py +119 -0
  67. mobile_agent/attach/web.py +13 -0
  68. mobile_agent/attachments/__init__.py +1 -0
  69. mobile_agent/attachments/agent.py +418 -0
  70. mobile_agent/attachments/extract.py +217 -0
  71. mobile_agent/attachments/mcp.py +104 -0
  72. mobile_agent/attachments/register.py +73 -0
  73. mobile_agent/attachments/routes.py +326 -0
  74. mobile_agent/attachments/service.py +170 -0
  75. mobile_agent/attachments/sniff.py +152 -0
  76. mobile_agent/attachments/store.py +441 -0
  77. mobile_agent/audit_verify.py +436 -0
  78. mobile_agent/bench/README.md +410 -0
  79. mobile_agent/bench/__init__.py +0 -0
  80. mobile_agent/bench/__main__.py +394 -0
  81. mobile_agent/bench/ab.py +168 -0
  82. mobile_agent/bench/actuation.py +368 -0
  83. mobile_agent/bench/agents/__init__.py +9 -0
  84. mobile_agent/bench/agents/base.py +105 -0
  85. mobile_agent/bench/agents/plan_clients.py +245 -0
  86. mobile_agent/bench/agents/smart.py +533 -0
  87. mobile_agent/bench/checks.py +358 -0
  88. mobile_agent/bench/decide_gate.py +214 -0
  89. mobile_agent/bench/diagnose.py +181 -0
  90. mobile_agent/bench/evals/__init__.py +14 -0
  91. mobile_agent/bench/evals/build.py +565 -0
  92. mobile_agent/bench/evals/checkpoint.py +214 -0
  93. mobile_agent/bench/evals/irt.py +219 -0
  94. mobile_agent/bench/evals/l0.py +647 -0
  95. mobile_agent/bench/evals/l1.py +866 -0
  96. mobile_agent/bench/evals/models.py +124 -0
  97. mobile_agent/bench/evals/pairwise.py +189 -0
  98. mobile_agent/bench/evals/proxy.py +90 -0
  99. mobile_agent/bench/evals/sets.py +149 -0
  100. mobile_agent/bench/evals/traces.py +446 -0
  101. mobile_agent/bench/fixtures.py +176 -0
  102. mobile_agent/bench/guard_replay.py +129 -0
  103. mobile_agent/bench/h2-manifest.json +20 -0
  104. mobile_agent/bench/h3-manifest.json +20 -0
  105. mobile_agent/bench/heldout-manifest.json +32 -0
  106. mobile_agent/bench/heldout.py +375 -0
  107. mobile_agent/bench/iosworld.py +901 -0
  108. mobile_agent/bench/iosworld_stats.py +510 -0
  109. mobile_agent/bench/llm_units.py +116 -0
  110. mobile_agent/bench/manifest.json +93 -0
  111. mobile_agent/bench/metrics.py +66 -0
  112. mobile_agent/bench/phone.py +44 -0
  113. mobile_agent/bench/replay.py +172 -0
  114. mobile_agent/bench/report.py +207 -0
  115. mobile_agent/bench/runner.py +456 -0
  116. mobile_agent/bench/safety.py +310 -0
  117. mobile_agent/bench/stream_timing.py +213 -0
  118. mobile_agent/bench/suite.py +444 -0
  119. mobile_agent/bench/sweep.py +176 -0
  120. mobile_agent/bench/tape.py +798 -0
  121. mobile_agent/bench/tape_replay.py +88 -0
  122. mobile_agent/bench/trace_evals.py +128 -0
  123. mobile_agent/bench/trace_view.py +81 -0
  124. mobile_agent/bench/truth/iphone15pro.json +138 -0
  125. mobile_agent/bench/truth.py +340 -0
  126. mobile_agent/bench/tuning.py +543 -0
  127. mobile_agent/bench/tuning_analysis.py +656 -0
  128. mobile_agent/bench/vague-heldout-manifest.json +12 -0
  129. mobile_agent/bench/vague_rubric.py +135 -0
  130. mobile_agent/bench/vague_run.py +243 -0
  131. mobile_agent/brand.py +454 -0
  132. mobile_agent/brand_fonts.py +756 -0
  133. mobile_agent/build_identity.py +21 -0
  134. mobile_agent/cable_bt.py +623 -0
  135. mobile_agent/cable_bt_runtime.py +128 -0
  136. mobile_agent/catalog.py +69 -0
  137. mobile_agent/chart_svg.py +415 -0
  138. mobile_agent/chat_here.py +189 -0
  139. mobile_agent/cli_constants.py +5 -0
  140. mobile_agent/cli_parser.py +611 -0
  141. mobile_agent/cli_run.py +115 -0
  142. mobile_agent/compiled_read/__init__.py +21 -0
  143. mobile_agent/compiled_read/delta.py +89 -0
  144. mobile_agent/compiled_read/extractors.py +202 -0
  145. mobile_agent/compiled_read/routes.py +56 -0
  146. mobile_agent/completion.py +267 -0
  147. mobile_agent/compose.py +48 -0
  148. mobile_agent/config.py +114 -0
  149. mobile_agent/connectors/__init__.py +1 -0
  150. mobile_agent/connectors/api_session.py +76 -0
  151. mobile_agent/connectors/callback_page.py +146 -0
  152. mobile_agent/connectors/client.py +582 -0
  153. mobile_agent/connectors/github_app.py +55 -0
  154. mobile_agent/connectors/graph_api.py +396 -0
  155. mobile_agent/connectors/imports.py +170 -0
  156. mobile_agent/connectors/issue.py +66 -0
  157. mobile_agent/connectors/keystore.py +85 -0
  158. mobile_agent/connectors/marks.py +222 -0
  159. mobile_agent/connectors/oauth.py +476 -0
  160. mobile_agent/connectors/picker.py +228 -0
  161. mobile_agent/connectors/presets.py +591 -0
  162. mobile_agent/connectors/register.py +48 -0
  163. mobile_agent/connectors/routes.py +153 -0
  164. mobile_agent/connectors/service.py +966 -0
  165. mobile_agent/connectors/shared.py +136 -0
  166. mobile_agent/connectors/skill.py +446 -0
  167. mobile_agent/connectors/slack_api.py +269 -0
  168. mobile_agent/connectors/steps.py +92 -0
  169. mobile_agent/connectors/store.py +144 -0
  170. mobile_agent/connectors/vault.py +72 -0
  171. mobile_agent/console.py +178 -0
  172. mobile_agent/content_queue/__init__.py +1 -0
  173. mobile_agent/content_queue/client.py +263 -0
  174. mobile_agent/content_queue/importing.py +153 -0
  175. mobile_agent/content_queue/media_check.py +224 -0
  176. mobile_agent/content_queue/prompt.py +64 -0
  177. mobile_agent/content_queue/register.py +91 -0
  178. mobile_agent/content_queue/routes.py +370 -0
  179. mobile_agent/content_queue/runner.py +475 -0
  180. mobile_agent/content_queue/store.py +521 -0
  181. mobile_agent/contract.py +996 -0
  182. mobile_agent/contract_asks.py +248 -0
  183. mobile_agent/contract_prompt.py +45 -0
  184. mobile_agent/contract_screen.py +981 -0
  185. mobile_agent/contract_view.py +84 -0
  186. mobile_agent/corpus/__init__.py +26 -0
  187. mobile_agent/corpus/cli.py +63 -0
  188. mobile_agent/corpus/consent.py +55 -0
  189. mobile_agent/corpus/listener.py +76 -0
  190. mobile_agent/corpus/register.py +9 -0
  191. mobile_agent/corpus/skeleton.py +72 -0
  192. mobile_agent/corpus/store.py +143 -0
  193. mobile_agent/costs.py +161 -0
  194. mobile_agent/decider_scope.py +40 -0
  195. mobile_agent/decision_provider.py +189 -0
  196. mobile_agent/deeplinks.py +352 -0
  197. mobile_agent/device_info.py +116 -0
  198. mobile_agent/device_manager.py +978 -0
  199. mobile_agent/device_status.py +195 -0
  200. mobile_agent/device_targets.py +217 -0
  201. mobile_agent/devices.py +676 -0
  202. mobile_agent/devkit/__init__.py +64 -0
  203. mobile_agent/devkit/account_cli.py +90 -0
  204. mobile_agent/devkit/asc.py +562 -0
  205. mobile_agent/devkit/asc_cli.py +214 -0
  206. mobile_agent/devkit/ci.py +737 -0
  207. mobile_agent/devkit/ci_cli.py +196 -0
  208. mobile_agent/devkit/ci_hosts.py +194 -0
  209. mobile_agent/devkit/es256.py +197 -0
  210. mobile_agent/devkit/expo.py +440 -0
  211. mobile_agent/devkit/expo_cli.py +405 -0
  212. mobile_agent/devkit/expo_dev.py +203 -0
  213. mobile_agent/devkit/expo_routes.py +90 -0
  214. mobile_agent/devkit/fixtures_cli.py +133 -0
  215. mobile_agent/devkit/fixtures_routes.py +82 -0
  216. mobile_agent/devkit/links.py +375 -0
  217. mobile_agent/devkit/links_cli.py +123 -0
  218. mobile_agent/devkit/links_routes.py +83 -0
  219. mobile_agent/devkit/nightly.py +152 -0
  220. mobile_agent/devkit/shots.py +203 -0
  221. mobile_agent/devkit/testflight.py +155 -0
  222. mobile_agent/devkit/testflight_cli.py +127 -0
  223. mobile_agent/devkit/testflight_routes.py +111 -0
  224. mobile_agent/devkit/xcode.py +236 -0
  225. mobile_agent/devkit/xcode_cli.py +101 -0
  226. mobile_agent/devtools.py +419 -0
  227. mobile_agent/doctor.py +509 -0
  228. mobile_agent/doctor_fix.py +467 -0
  229. mobile_agent/downloads.py +55 -0
  230. mobile_agent/drivers.py +793 -0
  231. mobile_agent/dry_run.py +102 -0
  232. mobile_agent/effect_ledger.py +83 -0
  233. mobile_agent/engine_health.py +378 -0
  234. mobile_agent/engine_hooks.py +96 -0
  235. mobile_agent/engines.py +408 -0
  236. mobile_agent/env_file.py +63 -0
  237. mobile_agent/env_secrets.py +149 -0
  238. mobile_agent/errors.py +15 -0
  239. mobile_agent/evals/__init__.py +3 -0
  240. mobile_agent/evals/app_survey.py +149 -0
  241. mobile_agent/evals/development/__init__.py +1 -0
  242. mobile_agent/evals/development/score.py +276 -0
  243. mobile_agent/evals/latency_report.py +312 -0
  244. mobile_agent/evals/offline_report.py +374 -0
  245. mobile_agent/evals/oracles.py +298 -0
  246. mobile_agent/evals/redteam/__init__.py +15 -0
  247. mobile_agent/evals/redteam/__main__.py +72 -0
  248. mobile_agent/evals/redteam/cases.py +730 -0
  249. mobile_agent/evals/redteam/harness.py +604 -0
  250. mobile_agent/evals/redteam/live.py +139 -0
  251. mobile_agent/evals/redteam/plain.py +85 -0
  252. mobile_agent/evals/vision/__init__.py +0 -0
  253. mobile_agent/evals/vision/images/dog_001.jpg +0 -0
  254. mobile_agent/evals/vision/images/dog_002.jpg +0 -0
  255. mobile_agent/evals/vision/images/dog_003.jpg +0 -0
  256. mobile_agent/evals/vision/images/dog_004.jpg +0 -0
  257. mobile_agent/evals/vision/images/dog_005.jpg +0 -0
  258. mobile_agent/evals/vision/images/dog_006.jpg +0 -0
  259. mobile_agent/evals/vision/images/dog_007.jpg +0 -0
  260. mobile_agent/evals/vision/images/dog_010.jpg +0 -0
  261. mobile_agent/evals/vision/images/dog_011.jpg +0 -0
  262. mobile_agent/evals/vision/images/dog_013.jpg +0 -0
  263. mobile_agent/evals/vision/images/dog_014.jpg +0 -0
  264. mobile_agent/evals/vision/images/dog_015.jpg +0 -0
  265. mobile_agent/evals/vision/images/dog_016.jpg +0 -0
  266. mobile_agent/evals/vision/images/dog_017.jpg +0 -0
  267. mobile_agent/evals/vision/images/dog_019.jpg +0 -0
  268. mobile_agent/evals/vision/images/dog_020.jpg +0 -0
  269. mobile_agent/evals/vision/images/dog_021.jpg +0 -0
  270. mobile_agent/evals/vision/images/dog_022.jpg +0 -0
  271. mobile_agent/evals/vision/images/dog_023.jpg +0 -0
  272. mobile_agent/evals/vision/images/dog_024.jpg +0 -0
  273. mobile_agent/evals/vision/images/dog_026.jpg +0 -0
  274. mobile_agent/evals/vision/images/dog_027.jpg +0 -0
  275. mobile_agent/evals/vision/images/dog_034.jpg +0 -0
  276. mobile_agent/evals/vision/images/dog_035.jpg +0 -0
  277. mobile_agent/evals/vision/images/dog_038.jpg +0 -0
  278. mobile_agent/evals/vision/images/dog_042.jpg +0 -0
  279. mobile_agent/evals/vision/images/dog_047.jpg +0 -0
  280. mobile_agent/evals/vision/images/dog_052.jpg +0 -0
  281. mobile_agent/evals/vision/images/dog_058.jpg +0 -0
  282. mobile_agent/evals/vision/images/dog_062.jpg +0 -0
  283. mobile_agent/evals/vision/images/eyes_000.jpg +0 -0
  284. mobile_agent/evals/vision/images/eyes_010.jpg +0 -0
  285. mobile_agent/evals/vision/images/eyes_011.jpg +0 -0
  286. mobile_agent/evals/vision/images/eyes_015.jpg +0 -0
  287. mobile_agent/evals/vision/images/eyes_016.jpg +0 -0
  288. mobile_agent/evals/vision/images/eyes_017.jpg +0 -0
  289. mobile_agent/evals/vision/images/eyes_019.jpg +0 -0
  290. mobile_agent/evals/vision/images/eyes_021.jpg +0 -0
  291. mobile_agent/evals/vision/images/eyes_025.jpg +0 -0
  292. mobile_agent/evals/vision/images/eyes_026.jpg +0 -0
  293. mobile_agent/evals/vision/images/eyes_027.jpg +0 -0
  294. mobile_agent/evals/vision/images/eyes_030.jpg +0 -0
  295. mobile_agent/evals/vision/images/eyes_032.jpg +0 -0
  296. mobile_agent/evals/vision/images/eyes_033.jpg +0 -0
  297. mobile_agent/evals/vision/images/eyes_034.jpg +0 -0
  298. mobile_agent/evals/vision/images/eyes_035.jpg +0 -0
  299. mobile_agent/evals/vision/images/eyes_036.jpg +0 -0
  300. mobile_agent/evals/vision/images/eyes_037.jpg +0 -0
  301. mobile_agent/evals/vision/images/eyes_039.jpg +0 -0
  302. mobile_agent/evals/vision/images/eyes_040.jpg +0 -0
  303. mobile_agent/evals/vision/images/eyes_041.jpg +0 -0
  304. mobile_agent/evals/vision/images/eyes_042.jpg +0 -0
  305. mobile_agent/evals/vision/images/eyes_043.jpg +0 -0
  306. mobile_agent/evals/vision/images/eyes_044.jpg +0 -0
  307. mobile_agent/evals/vision/images/eyes_046.jpg +0 -0
  308. mobile_agent/evals/vision/images/eyes_047.jpg +0 -0
  309. mobile_agent/evals/vision/images/eyes_048.jpg +0 -0
  310. mobile_agent/evals/vision/images/eyes_049.jpg +0 -0
  311. mobile_agent/evals/vision/images/eyes_051.jpg +0 -0
  312. mobile_agent/evals/vision/images/eyes_053.jpg +0 -0
  313. mobile_agent/evals/vision/images/eyes_054.jpg +0 -0
  314. mobile_agent/evals/vision/images/eyes_055.jpg +0 -0
  315. mobile_agent/evals/vision/images/eyes_058.jpg +0 -0
  316. mobile_agent/evals/vision/images/eyes_059.jpg +0 -0
  317. mobile_agent/evals/vision/images/receipt_002.jpg +0 -0
  318. mobile_agent/evals/vision/images/receipt_003.jpg +0 -0
  319. mobile_agent/evals/vision/images/receipt_004.jpg +0 -0
  320. mobile_agent/evals/vision/images/receipt_007.jpg +0 -0
  321. mobile_agent/evals/vision/images/receipt_009.jpg +0 -0
  322. mobile_agent/evals/vision/images/receipt_010.jpg +0 -0
  323. mobile_agent/evals/vision/images/receipt_011.jpg +0 -0
  324. mobile_agent/evals/vision/images/receipt_012.jpg +0 -0
  325. mobile_agent/evals/vision/images/receipt_013.jpg +0 -0
  326. mobile_agent/evals/vision/images/receipt_014.jpg +0 -0
  327. mobile_agent/evals/vision/images/receipt_016.jpg +0 -0
  328. mobile_agent/evals/vision/images/receipt_017.jpg +0 -0
  329. mobile_agent/evals/vision/images/receipt_023.jpg +0 -0
  330. mobile_agent/evals/vision/images/receipt_027.jpg +0 -0
  331. mobile_agent/evals/vision/images/receipt_029.jpg +0 -0
  332. mobile_agent/evals/vision/images/receipt_032.jpg +0 -0
  333. mobile_agent/evals/vision/images/receipt_033.jpg +0 -0
  334. mobile_agent/evals/vision/images/receipt_036.jpg +0 -0
  335. mobile_agent/evals/vision/images/receipt_041.jpg +0 -0
  336. mobile_agent/evals/vision/images/receipt_043.jpg +0 -0
  337. mobile_agent/evals/vision/images/receipt_047.jpg +0 -0
  338. mobile_agent/evals/vision/images/receipt_049.jpg +0 -0
  339. mobile_agent/evals/vision/images/receipt_051.jpg +0 -0
  340. mobile_agent/evals/vision/images/receipt_053.jpg +0 -0
  341. mobile_agent/evals/vision/images/receipt_056.jpg +0 -0
  342. mobile_agent/evals/vision/images/receipt_057.jpg +0 -0
  343. mobile_agent/evals/vision/images/receipt_058.jpg +0 -0
  344. mobile_agent/evals/vision/images/receipt_059.jpg +0 -0
  345. mobile_agent/evals/vision/images/receipt_060.jpg +0 -0
  346. mobile_agent/evals/vision/images/receipt_061.jpg +0 -0
  347. mobile_agent/evals/vision/images/receipt_062.jpg +0 -0
  348. mobile_agent/evals/vision/images/receipt_063.jpg +0 -0
  349. mobile_agent/evals/vision/images/receipt_064.jpg +0 -0
  350. mobile_agent/evals/vision/images/receipt_065.jpg +0 -0
  351. mobile_agent/evals/vision/images/receipt_066.jpg +0 -0
  352. mobile_agent/evals/vision/images/receipt_067.jpg +0 -0
  353. mobile_agent/evals/vision/images/receipt_068.jpg +0 -0
  354. mobile_agent/evals/vision/images/receipt_069.jpg +0 -0
  355. mobile_agent/evals/vision/images/receipt_070.jpg +0 -0
  356. mobile_agent/evals/vision/images/receipt_071.jpg +0 -0
  357. mobile_agent/evals/vision/images/sunglasses_000.jpg +0 -0
  358. mobile_agent/evals/vision/images/sunglasses_001.jpg +0 -0
  359. mobile_agent/evals/vision/images/sunglasses_002.jpg +0 -0
  360. mobile_agent/evals/vision/images/sunglasses_004.jpg +0 -0
  361. mobile_agent/evals/vision/images/sunglasses_005.jpg +0 -0
  362. mobile_agent/evals/vision/images/sunglasses_008.jpg +0 -0
  363. mobile_agent/evals/vision/images/sunglasses_009.jpg +0 -0
  364. mobile_agent/evals/vision/images/sunglasses_010.jpg +0 -0
  365. mobile_agent/evals/vision/images/sunglasses_012.jpg +0 -0
  366. mobile_agent/evals/vision/images/sunglasses_013.jpg +0 -0
  367. mobile_agent/evals/vision/images/sunglasses_015.jpg +0 -0
  368. mobile_agent/evals/vision/images/sunglasses_016.jpg +0 -0
  369. mobile_agent/evals/vision/images/sunglasses_017.jpg +0 -0
  370. mobile_agent/evals/vision/images/sunglasses_018.jpg +0 -0
  371. mobile_agent/evals/vision/images/sunglasses_021.jpg +0 -0
  372. mobile_agent/evals/vision/images/sunglasses_022.jpg +0 -0
  373. mobile_agent/evals/vision/images/sunglasses_023.jpg +0 -0
  374. mobile_agent/evals/vision/images/sunglasses_024.jpg +0 -0
  375. mobile_agent/evals/vision/images/sunglasses_030.jpg +0 -0
  376. mobile_agent/evals/vision/images/sunglasses_031.jpg +0 -0
  377. mobile_agent/evals/vision/images/sunglasses_032.jpg +0 -0
  378. mobile_agent/evals/vision/images/sunglasses_033.jpg +0 -0
  379. mobile_agent/evals/vision/images/sunglasses_034.jpg +0 -0
  380. mobile_agent/evals/vision/images/sunglasses_036.jpg +0 -0
  381. mobile_agent/evals/vision/images/sunglasses_038.jpg +0 -0
  382. mobile_agent/evals/vision/images/sunglasses_039.jpg +0 -0
  383. mobile_agent/evals/vision/images/sunglasses_040.jpg +0 -0
  384. mobile_agent/evals/vision/images/sunglasses_041.jpg +0 -0
  385. mobile_agent/evals/vision/manifest.json +2355 -0
  386. mobile_agent/eventbus.py +110 -0
  387. mobile_agent/explain.py +34 -0
  388. mobile_agent/failure_line.py +197 -0
  389. mobile_agent/fanout.py +663 -0
  390. mobile_agent/feedback/__init__.py +2 -0
  391. mobile_agent/feedback/bundle.py +204 -0
  392. mobile_agent/feedback/outbox.py +196 -0
  393. mobile_agent/feedback/register.py +9 -0
  394. mobile_agent/feedback/routes.py +177 -0
  395. mobile_agent/fleet.py +841 -0
  396. mobile_agent/fleet_health.py +729 -0
  397. mobile_agent/fleet_phone.py +186 -0
  398. mobile_agent/frontier/__init__.py +79 -0
  399. mobile_agent/frontier/act.py +380 -0
  400. mobile_agent/frontier/actions.py +70 -0
  401. mobile_agent/frontier/agent.py +256 -0
  402. mobile_agent/frontier/approvals.py +366 -0
  403. mobile_agent/frontier/hooks.py +199 -0
  404. mobile_agent/frontier/interrupts.py +125 -0
  405. mobile_agent/frontier/observe.py +331 -0
  406. mobile_agent/frontier/phases.py +381 -0
  407. mobile_agent/frontier/pipeline.py +61 -0
  408. mobile_agent/frontier/prompt.py +384 -0
  409. mobile_agent/frontier/protocol.py +91 -0
  410. mobile_agent/frontier/safety.py +467 -0
  411. mobile_agent/frontier/saved.py +375 -0
  412. mobile_agent/frontier/screen.py +419 -0
  413. mobile_agent/frontier/skills.py +147 -0
  414. mobile_agent/frontier/stages/__init__.py +1 -0
  415. mobile_agent/frontier/stages/after.py +192 -0
  416. mobile_agent/frontier/stages/approve.py +138 -0
  417. mobile_agent/frontier/stages/controls.py +115 -0
  418. mobile_agent/frontier/stages/ends.py +102 -0
  419. mobile_agent/frontier/stages/gate.py +92 -0
  420. mobile_agent/frontier/stages/guards.py +132 -0
  421. mobile_agent/frontier/stages/points.py +155 -0
  422. mobile_agent/frontier/stages/send.py +177 -0
  423. mobile_agent/frontier/turn.py +151 -0
  424. mobile_agent/full_disk_access.py +83 -0
  425. mobile_agent/gemini.py +69 -0
  426. mobile_agent/genuine.py +120 -0
  427. mobile_agent/graph/__init__.py +4 -0
  428. mobile_agent/graph/catalog.py +789 -0
  429. mobile_agent/graph/chain.py +306 -0
  430. mobile_agent/graph/dispatch.py +98 -0
  431. mobile_agent/graph/draft.py +373 -0
  432. mobile_agent/graph/edit.py +76 -0
  433. mobile_agent/graph/estimate.py +189 -0
  434. mobile_agent/graph/expr.py +562 -0
  435. mobile_agent/graph/functions.py +271 -0
  436. mobile_agent/graph/infer.py +144 -0
  437. mobile_agent/graph/nodes.py +936 -0
  438. mobile_agent/graph/phone_steps.py +178 -0
  439. mobile_agent/graph/rerun.py +327 -0
  440. mobile_agent/graph/run.py +899 -0
  441. mobile_agent/graph/schema.py +842 -0
  442. mobile_agent/graph/state.py +161 -0
  443. mobile_agent/graph/values.py +198 -0
  444. mobile_agent/harness/__init__.py +2 -0
  445. mobile_agent/harness/checkpoints.py +468 -0
  446. mobile_agent/harness/control.py +101 -0
  447. mobile_agent/harness/prewarm.py +213 -0
  448. mobile_agent/harness/register.py +43 -0
  449. mobile_agent/harness/routes.py +114 -0
  450. mobile_agent/harness/settings.py +66 -0
  451. mobile_agent/harness/tools/__init__.py +67 -0
  452. mobile_agent/harness/tools/ask_user.py +102 -0
  453. mobile_agent/harness/tools/watch.py +120 -0
  454. mobile_agent/harness_api.py +815 -0
  455. mobile_agent/hedge.py +203 -0
  456. mobile_agent/help_topics.py +305 -0
  457. mobile_agent/hints.py +248 -0
  458. mobile_agent/history.py +69 -0
  459. mobile_agent/home.py +214 -0
  460. mobile_agent/http_pool.py +175 -0
  461. mobile_agent/image_policy.py +139 -0
  462. mobile_agent/inbox.py +148 -0
  463. mobile_agent/installed_apps.py +344 -0
  464. mobile_agent/integrations/__init__.py +1 -0
  465. mobile_agent/integrations/cli.py +472 -0
  466. mobile_agent/integrations/client_files.py +65 -0
  467. mobile_agent/integrations/clients.py +998 -0
  468. mobile_agent/integrations/doctor.py +185 -0
  469. mobile_agent/integrations/jsonc.py +383 -0
  470. mobile_agent/integrations/routes.py +135 -0
  471. mobile_agent/integrations/rules.py +112 -0
  472. mobile_agent/integrations/server_options.py +46 -0
  473. mobile_agent/integrations/skill.py +152 -0
  474. mobile_agent/integrations/skill_files.py +10 -0
  475. mobile_agent/integrations/textedit.py +309 -0
  476. mobile_agent/intents/__init__.py +17 -0
  477. mobile_agent/intents/atlas.py +216 -0
  478. mobile_agent/intents/callback.py +44 -0
  479. mobile_agent/intents/cli.py +55 -0
  480. mobile_agent/intents/harvest.py +91 -0
  481. mobile_agent/intents/plan.py +93 -0
  482. mobile_agent/ios/__init__.py +1 -0
  483. mobile_agent/ios/guide.py +16 -0
  484. mobile_agent/ios/kb.py +212 -0
  485. mobile_agent/ios/kb_data.py +1588 -0
  486. mobile_agent/ios/mcp.py +44 -0
  487. mobile_agent/ios/register.py +8 -0
  488. mobile_agent/ios/system.py +369 -0
  489. mobile_agent/journal.py +531 -0
  490. mobile_agent/keychain_acl.py +374 -0
  491. mobile_agent/keys.py +395 -0
  492. mobile_agent/latency_overlap.py +36 -0
  493. mobile_agent/latency_trace.py +170 -0
  494. mobile_agent/learned/__init__.py +15 -0
  495. mobile_agent/learned/app_path.py +160 -0
  496. mobile_agent/learned/code_read.py +38 -0
  497. mobile_agent/learned/parts.py +546 -0
  498. mobile_agent/learned/programs.py +691 -0
  499. mobile_agent/learned/register.py +171 -0
  500. mobile_agent/learned/routes.py +61 -0
  501. mobile_agent/learned/taught.py +329 -0
  502. mobile_agent/learned/teach.py +538 -0
  503. mobile_agent/learned/workflows.py +647 -0
  504. mobile_agent/learned/zero.py +41 -0
  505. mobile_agent/links.py +37 -0
  506. mobile_agent/listen.py +607 -0
  507. mobile_agent/lock_spike.py +278 -0
  508. mobile_agent/lockscreen.py +526 -0
  509. mobile_agent/login.py +354 -0
  510. mobile_agent/longform/__init__.py +15 -0
  511. mobile_agent/longform/ads.py +191 -0
  512. mobile_agent/longform/blocks.py +117 -0
  513. mobile_agent/longform/budget.py +265 -0
  514. mobile_agent/longform/context.py +285 -0
  515. mobile_agent/longform/costs.py +93 -0
  516. mobile_agent/longform/digest.py +145 -0
  517. mobile_agent/longform/extract.py +508 -0
  518. mobile_agent/longform/feed.py +505 -0
  519. mobile_agent/longform/followup.py +168 -0
  520. mobile_agent/longform/frames.py +82 -0
  521. mobile_agent/longform/identity.py +87 -0
  522. mobile_agent/longform/inbox.py +221 -0
  523. mobile_agent/longform/items.py +425 -0
  524. mobile_agent/longform/models.py +115 -0
  525. mobile_agent/longform/notebook.py +329 -0
  526. mobile_agent/longform/plan.py +614 -0
  527. mobile_agent/longform/recall.py +195 -0
  528. mobile_agent/longform/receipts.py +81 -0
  529. mobile_agent/longform/register.py +124 -0
  530. mobile_agent/longform/report.py +343 -0
  531. mobile_agent/longform/routes.py +102 -0
  532. mobile_agent/longform/session.py +644 -0
  533. mobile_agent/longform/store.py +146 -0
  534. mobile_agent/longform/synthesis.py +347 -0
  535. mobile_agent/mac_tools.py +223 -0
  536. mobile_agent/manual_control.py +143 -0
  537. mobile_agent/maps.py +149 -0
  538. mobile_agent/marks.py +92 -0
  539. mobile_agent/mcp_server/__init__.py +3 -0
  540. mobile_agent/mcp_server/__main__.py +29 -0
  541. mobile_agent/mcp_server/action_journal.py +24 -0
  542. mobile_agent/mcp_server/action_tools.py +638 -0
  543. mobile_agent/mcp_server/approvals.py +219 -0
  544. mobile_agent/mcp_server/catalog.py +92 -0
  545. mobile_agent/mcp_server/cli.py +194 -0
  546. mobile_agent/mcp_server/codes.py +147 -0
  547. mobile_agent/mcp_server/device_tools.py +306 -0
  548. mobile_agent/mcp_server/direct.py +272 -0
  549. mobile_agent/mcp_server/expo_tools.py +105 -0
  550. mobile_agent/mcp_server/phone_tools.py +510 -0
  551. mobile_agent/mcp_server/protocol.py +364 -0
  552. mobile_agent/mcp_server/registry.py +55 -0
  553. mobile_agent/mcp_server/run_tools.py +531 -0
  554. mobile_agent/mcp_server/schemas.py +425 -0
  555. mobile_agent/mcp_server/screen_tools.py +168 -0
  556. mobile_agent/mcp_server/session.py +469 -0
  557. mobile_agent/mcp_server/support.py +145 -0
  558. mobile_agent/mcp_server/tools.py +493 -0
  559. mobile_agent/memory/__init__.py +9 -0
  560. mobile_agent/memory/cli.py +207 -0
  561. mobile_agent/memory/extract.py +191 -0
  562. mobile_agent/memory/listener.py +100 -0
  563. mobile_agent/memory/model.py +144 -0
  564. mobile_agent/memory/project.py +96 -0
  565. mobile_agent/memory/provider.py +127 -0
  566. mobile_agent/memory/register.py +21 -0
  567. mobile_agent/memory/retrieval.py +172 -0
  568. mobile_agent/memory/routes.py +216 -0
  569. mobile_agent/memory/runs.py +124 -0
  570. mobile_agent/memory/store.py +806 -0
  571. mobile_agent/memory/writer.py +278 -0
  572. mobile_agent/mirroring.py +585 -0
  573. mobile_agent/mirroring_runtime.py +221 -0
  574. mobile_agent/model_clients.py +604 -0
  575. mobile_agent/narrate.py +813 -0
  576. mobile_agent/native_helpers.py +84 -0
  577. mobile_agent/netxray/__init__.py +1 -0
  578. mobile_agent/netxray/analyze.py +322 -0
  579. mobile_agent/netxray/attribution.py +62 -0
  580. mobile_agent/netxray/capture.py +88 -0
  581. mobile_agent/netxray/cli.py +142 -0
  582. mobile_agent/netxray/job.py +219 -0
  583. mobile_agent/netxray/mobster-pcap.c +560 -0
  584. mobile_agent/netxray/packets.py +410 -0
  585. mobile_agent/netxray/pcapng.py +223 -0
  586. mobile_agent/netxray/register.py +93 -0
  587. mobile_agent/netxray/report.py +214 -0
  588. mobile_agent/netxray/store.py +48 -0
  589. mobile_agent/netxray/summary.py +157 -0
  590. mobile_agent/netxray/target.py +67 -0
  591. mobile_agent/netxray/tool.py +66 -0
  592. mobile_agent/netxray/trackers.py +194 -0
  593. mobile_agent/notify_triggers/__init__.py +1 -0
  594. mobile_agent/notify_triggers/rules.py +39 -0
  595. mobile_agent/notify_triggers/sources.py +315 -0
  596. mobile_agent/notify_triggers/watch.py +135 -0
  597. mobile_agent/offers.py +86 -0
  598. mobile_agent/orchestrate.py +215 -0
  599. mobile_agent/outlets/__init__.py +1 -0
  600. mobile_agent/outlets/connect.py +249 -0
  601. mobile_agent/outlets/folder.py +180 -0
  602. mobile_agent/outlets/hooks.py +453 -0
  603. mobile_agent/outlets/inbound.py +472 -0
  604. mobile_agent/outlets/inbound_reply.py +57 -0
  605. mobile_agent/outlets/inbound_routes.py +81 -0
  606. mobile_agent/outlets/kinds.py +308 -0
  607. mobile_agent/outlets/mail.py +110 -0
  608. mobile_agent/outlets/net.py +86 -0
  609. mobile_agent/outlets/notion.py +244 -0
  610. mobile_agent/outlets/register.py +56 -0
  611. mobile_agent/outlets/routes.py +135 -0
  612. mobile_agent/outlets/service.py +580 -0
  613. mobile_agent/outlets/sheets.py +169 -0
  614. mobile_agent/outlets/slack.py +177 -0
  615. mobile_agent/outlets/store.py +194 -0
  616. mobile_agent/outlets/vault.py +80 -0
  617. mobile_agent/outlets/webhook.py +316 -0
  618. mobile_agent/output_contract.py +141 -0
  619. mobile_agent/owner_presence.py +211 -0
  620. mobile_agent/ownership.py +219 -0
  621. mobile_agent/packs/__init__.py +1 -0
  622. mobile_agent/packs/calendar.py +243 -0
  623. mobile_agent/packs/context.py +68 -0
  624. mobile_agent/packs/core.py +757 -0
  625. mobile_agent/packs/front_door.py +316 -0
  626. mobile_agent/packs/heal.py +214 -0
  627. mobile_agent/packs/health.py +122 -0
  628. mobile_agent/packs/insights.py +76 -0
  629. mobile_agent/packs/instagram.py +640 -0
  630. mobile_agent/packs/linkedin.py +106 -0
  631. mobile_agent/packs/maps.py +196 -0
  632. mobile_agent/packs/mcp.py +149 -0
  633. mobile_agent/packs/messages.py +270 -0
  634. mobile_agent/packs/notes.py +226 -0
  635. mobile_agent/packs/photos.py +166 -0
  636. mobile_agent/packs/pins.py +47 -0
  637. mobile_agent/packs/reddit.py +110 -0
  638. mobile_agent/packs/register.py +276 -0
  639. mobile_agent/packs/rows.py +108 -0
  640. mobile_agent/packs/safari.py +143 -0
  641. mobile_agent/packs/settings.py +253 -0
  642. mobile_agent/packs/social.py +300 -0
  643. mobile_agent/packs/threads.py +97 -0
  644. mobile_agent/packs/tiktok.py +455 -0
  645. mobile_agent/packs/tools.py +290 -0
  646. mobile_agent/packs/x.py +127 -0
  647. mobile_agent/packs/youtube.py +199 -0
  648. mobile_agent/parity.py +270 -0
  649. mobile_agent/paths.py +99 -0
  650. mobile_agent/perception.py +470 -0
  651. mobile_agent/permissions/__init__.py +32 -0
  652. mobile_agent/permissions/consequence.py +535 -0
  653. mobile_agent/permissions/exceptions.py +99 -0
  654. mobile_agent/permissions/gate.py +316 -0
  655. mobile_agent/permissions/outside.py +316 -0
  656. mobile_agent/permissions/policy.py +288 -0
  657. mobile_agent/permissions/project_policy.py +411 -0
  658. mobile_agent/permissions/register.py +148 -0
  659. mobile_agent/permissions/rules.py +291 -0
  660. mobile_agent/phone_io/__init__.py +146 -0
  661. mobile_agent/phone_io/cli.py +427 -0
  662. mobile_agent/phone_io/clipboard.py +78 -0
  663. mobile_agent/phone_io/controls.py +78 -0
  664. mobile_agent/phone_io/files.py +289 -0
  665. mobile_agent/phone_io/handoff.py +123 -0
  666. mobile_agent/phone_io/icloud.py +118 -0
  667. mobile_agent/phone_io/install.py +188 -0
  668. mobile_agent/phone_io/network_capture.py +188 -0
  669. mobile_agent/phone_io/network_cli.py +138 -0
  670. mobile_agent/phone_io/network_profiles.py +138 -0
  671. mobile_agent/phone_io/photos.py +512 -0
  672. mobile_agent/phone_io/recording.py +115 -0
  673. mobile_agent/phone_io/secrets.py +146 -0
  674. mobile_agent/phone_io/transfer.py +895 -0
  675. mobile_agent/phone_io/tunnel.py +129 -0
  676. mobile_agent/phone_ready.py +91 -0
  677. mobile_agent/pinning.py +158 -0
  678. mobile_agent/plan_gate.py +807 -0
  679. mobile_agent/plugins/__init__.py +3 -0
  680. mobile_agent/plugins/install.py +163 -0
  681. mobile_agent/plugins/manifest.py +242 -0
  682. mobile_agent/plugins/register.py +15 -0
  683. mobile_agent/plugins/routes.py +82 -0
  684. mobile_agent/plugins/service.py +322 -0
  685. mobile_agent/plugins/skills.py +76 -0
  686. mobile_agent/plugins/store.py +79 -0
  687. mobile_agent/pool.py +285 -0
  688. mobile_agent/power_watch.py +275 -0
  689. mobile_agent/premium_model.py +22 -0
  690. mobile_agent/project_env.py +291 -0
  691. mobile_agent/project_env_cli.py +74 -0
  692. mobile_agent/project_env_routes.py +55 -0
  693. mobile_agent/projects/__init__.py +27 -0
  694. mobile_agent/projects/ci.py +332 -0
  695. mobile_agent/projects/clone.py +339 -0
  696. mobile_agent/projects/drafts.py +158 -0
  697. mobile_agent/projects/folder.py +436 -0
  698. mobile_agent/projects/github.py +131 -0
  699. mobile_agent/projects/register.py +31 -0
  700. mobile_agent/projects/revisions.py +253 -0
  701. mobile_agent/projects/routes.py +234 -0
  702. mobile_agent/projects/runs.py +297 -0
  703. mobile_agent/projects/service.py +695 -0
  704. mobile_agent/projects/setup.py +84 -0
  705. mobile_agent/projects/store.py +379 -0
  706. mobile_agent/projects/sync.py +380 -0
  707. mobile_agent/projects/workflow_files.py +270 -0
  708. mobile_agent/replay_core/__init__.py +26 -0
  709. mobile_agent/replay_core/checks.py +594 -0
  710. mobile_agent/replay_core/engine.py +596 -0
  711. mobile_agent/replay_core/heal.py +364 -0
  712. mobile_agent/replay_core/path.py +240 -0
  713. mobile_agent/replay_core/recorder.py +206 -0
  714. mobile_agent/replay_core/store.py +171 -0
  715. mobile_agent/result_blocks.py +941 -0
  716. mobile_agent/run.py +642 -0
  717. mobile_agent/run_delivery.py +123 -0
  718. mobile_agent/run_executor.py +200 -0
  719. mobile_agent/run_export.py +284 -0
  720. mobile_agent/run_gif.py +891 -0
  721. mobile_agent/runner_hints.py +86 -0
  722. mobile_agent/runner_renewal.py +205 -0
  723. mobile_agent/runtime_create.py +549 -0
  724. mobile_agent/runtime_driver.py +458 -0
  725. mobile_agent/runtime_limits.py +319 -0
  726. mobile_agent/runtime_queue.py +394 -0
  727. mobile_agent/runtime_runs.py +383 -0
  728. mobile_agent/runtime_status.py +181 -0
  729. mobile_agent/runtime_teach.py +295 -0
  730. mobile_agent/runtime_work.py +599 -0
  731. mobile_agent/screen_text.py +101 -0
  732. mobile_agent/secret_filter.py +113 -0
  733. mobile_agent/serve.py +160 -0
  734. mobile_agent/server.py +363 -0
  735. mobile_agent/service_support.py +42 -0
  736. mobile_agent/settle/__init__.py +1 -0
  737. mobile_agent/settle/predict.py +102 -0
  738. mobile_agent/settle/replay.py +92 -0
  739. mobile_agent/settle/store.py +93 -0
  740. mobile_agent/settle/stream.py +43 -0
  741. mobile_agent/setup_cli.py +225 -0
  742. mobile_agent/setup_errors.py +87 -0
  743. mobile_agent/setup_service.py +527 -0
  744. mobile_agent/share.py +104 -0
  745. mobile_agent/signing.py +184 -0
  746. mobile_agent/sim/__init__.py +6 -0
  747. mobile_agent/sim/__main__.py +26 -0
  748. mobile_agent/sim/api.py +952 -0
  749. mobile_agent/sim/apps.py +172 -0
  750. mobile_agent/sim/biometrics.py +84 -0
  751. mobile_agent/sim/cli.py +271 -0
  752. mobile_agent/sim/doctor.py +293 -0
  753. mobile_agent/sim/governor.py +90 -0
  754. mobile_agent/sim/holders.py +88 -0
  755. mobile_agent/sim/models.py +62 -0
  756. mobile_agent/sim/registry.py +161 -0
  757. mobile_agent/sim/simctl.py +269 -0
  758. mobile_agent/sim/wda.py +618 -0
  759. mobile_agent/sim/xcode.py +74 -0
  760. mobile_agent/skills/__init__.py +12 -0
  761. mobile_agent/skills/codes.py +597 -0
  762. mobile_agent/skills/notifications.py +281 -0
  763. mobile_agent/skills/open_url.py +127 -0
  764. mobile_agent/smart_events.py +588 -0
  765. mobile_agent/smart_result.py +490 -0
  766. mobile_agent/smart_run.py +413 -0
  767. mobile_agent/state.py +662 -0
  768. mobile_agent/states.py +217 -0
  769. mobile_agent/steering.py +70 -0
  770. mobile_agent/style.py +178 -0
  771. mobile_agent/supervised.py +234 -0
  772. mobile_agent/swift/helper_bt_hid.swift +534 -0
  773. mobile_agent/swift/helper_doc_text.swift +184 -0
  774. mobile_agent/swift/helper_draft_reply.swift +170 -0
  775. mobile_agent/swift/helper_map.swift +110 -0
  776. mobile_agent/swift/helper_mirroring.swift +1099 -0
  777. mobile_agent/swift/helper_mjpeg_mp4.swift +103 -0
  778. mobile_agent/swift/helper_pdf.swift +100 -0
  779. mobile_agent/swift/helper_phone_audio.swift +993 -0
  780. mobile_agent/swift/helper_phone_screen.swift +416 -0
  781. mobile_agent/swift/helper_screen_text.swift +24 -0
  782. mobile_agent/swift/helper_transfer.swift +246 -0
  783. mobile_agent/swift/ocr.swift +22 -0
  784. mobile_agent/task_policy.py +337 -0
  785. mobile_agent/team_signing/__init__.py +34 -0
  786. mobile_agent/team_signing/apple_ca.py +37 -0
  787. mobile_agent/team_signing/bundle.py +136 -0
  788. mobile_agent/team_signing/cli.py +208 -0
  789. mobile_agent/team_signing/identity.py +248 -0
  790. mobile_agent/team_signing/ledger.py +105 -0
  791. mobile_agent/team_signing/prebuilt.py +61 -0
  792. mobile_agent/team_signing/resign.py +256 -0
  793. mobile_agent/team_signing/service.py +382 -0
  794. mobile_agent/team_signing/tooling.py +243 -0
  795. mobile_agent/team_signing/writes.py +268 -0
  796. mobile_agent/terminal_image.py +81 -0
  797. mobile_agent/testkit/__init__.py +1 -0
  798. mobile_agent/testkit/a11y_baseline.py +132 -0
  799. mobile_agent/testkit/api.py +403 -0
  800. mobile_agent/testkit/capsule.py +400 -0
  801. mobile_agent/testkit/capsule_cli.py +57 -0
  802. mobile_agent/testkit/cli.py +948 -0
  803. mobile_agent/testkit/cli_arguments.py +193 -0
  804. mobile_agent/testkit/comparison.py +288 -0
  805. mobile_agent/testkit/comparison_cli.py +41 -0
  806. mobile_agent/testkit/discover.py +257 -0
  807. mobile_agent/testkit/editor.py +243 -0
  808. mobile_agent/testkit/execute.py +515 -0
  809. mobile_agent/testkit/github_checks.py +248 -0
  810. mobile_agent/testkit/gitref.py +66 -0
  811. mobile_agent/testkit/heal_review.py +176 -0
  812. mobile_agent/testkit/health.py +480 -0
  813. mobile_agent/testkit/history.py +255 -0
  814. mobile_agent/testkit/hosted_results.py +321 -0
  815. mobile_agent/testkit/input_files.py +173 -0
  816. mobile_agent/testkit/jobs.py +285 -0
  817. mobile_agent/testkit/junit_verdict.py +33 -0
  818. mobile_agent/testkit/maestro.py +802 -0
  819. mobile_agent/testkit/mcp.py +562 -0
  820. mobile_agent/testkit/mcp_comparison.py +88 -0
  821. mobile_agent/testkit/minimize.py +471 -0
  822. mobile_agent/testkit/minimizer_cli.py +48 -0
  823. mobile_agent/testkit/notify.py +212 -0
  824. mobile_agent/testkit/phone_video.py +191 -0
  825. mobile_agent/testkit/pr.py +491 -0
  826. mobile_agent/testkit/pr_report.py +562 -0
  827. mobile_agent/testkit/prbuild.py +148 -0
  828. mobile_agent/testkit/proof_report.py +55 -0
  829. mobile_agent/testkit/provenance.py +119 -0
  830. mobile_agent/testkit/prrun.py +244 -0
  831. mobile_agent/testkit/recent.py +166 -0
  832. mobile_agent/testkit/recipes.py +146 -0
  833. mobile_agent/testkit/record.py +204 -0
  834. mobile_agent/testkit/register.py +288 -0
  835. mobile_agent/testkit/reports.py +998 -0
  836. mobile_agent/testkit/repro.py +432 -0
  837. mobile_agent/testkit/scenario.py +440 -0
  838. mobile_agent/testkit/scenario_cli.py +37 -0
  839. mobile_agent/testkit/snapshots_cli.py +60 -0
  840. mobile_agent/testkit/suite.py +670 -0
  841. mobile_agent/testkit/targets.py +497 -0
  842. mobile_agent/testkit/xctest.py +378 -0
  843. mobile_agent/threads/__init__.py +1 -0
  844. mobile_agent/threads/chat_once.py +264 -0
  845. mobile_agent/threads/cli.py +740 -0
  846. mobile_agent/threads/client.py +326 -0
  847. mobile_agent/threads/context.py +246 -0
  848. mobile_agent/threads/mcp.py +739 -0
  849. mobile_agent/threads/recent.py +34 -0
  850. mobile_agent/threads/register.py +54 -0
  851. mobile_agent/threads/routes.py +188 -0
  852. mobile_agent/threads/service.py +975 -0
  853. mobile_agent/threads/store.py +361 -0
  854. mobile_agent/threads/summary.py +117 -0
  855. mobile_agent/threads/term.py +101 -0
  856. mobile_agent/threads/workflow_cli.py +104 -0
  857. mobile_agent/tls.py +36 -0
  858. mobile_agent/traces.py +271 -0
  859. mobile_agent/tracks.py +235 -0
  860. mobile_agent/transport.py +271 -0
  861. mobile_agent/triggers/__init__.py +1 -0
  862. mobile_agent/triggers/actions.py +204 -0
  863. mobile_agent/triggers/model.py +146 -0
  864. mobile_agent/triggers/register.py +29 -0
  865. mobile_agent/triggers/routes.py +101 -0
  866. mobile_agent/triggers/service.py +387 -0
  867. mobile_agent/triggers/store.py +142 -0
  868. mobile_agent/tui/__init__.py +89 -0
  869. mobile_agent/tui/app.py +582 -0
  870. mobile_agent/tui/chrome.py +321 -0
  871. mobile_agent/tui/commands.py +375 -0
  872. mobile_agent/tui/demo_phone.py +369 -0
  873. mobile_agent/tui/images.py +29 -0
  874. mobile_agent/tui/keys.py +64 -0
  875. mobile_agent/tui/prompt.py +195 -0
  876. mobile_agent/tui/render.py +395 -0
  877. mobile_agent/tui/screens.py +180 -0
  878. mobile_agent/tui/session.py +308 -0
  879. mobile_agent/tui/tasks.py +287 -0
  880. mobile_agent/tui/theme.py +118 -0
  881. mobile_agent/tui/widgets.py +272 -0
  882. mobile_agent/update_check.py +213 -0
  883. mobile_agent/usage.py +184 -0
  884. mobile_agent/verify/__init__.py +0 -0
  885. mobile_agent/verify/__main__.py +41 -0
  886. mobile_agent/verify/a11y.py +189 -0
  887. mobile_agent/verify/actions.py +290 -0
  888. mobile_agent/verify/assertions.py +570 -0
  889. mobile_agent/verify/audit.py +312 -0
  890. mobile_agent/verify/business.py +221 -0
  891. mobile_agent/verify/business_run.py +131 -0
  892. mobile_agent/verify/business_state.py +168 -0
  893. mobile_agent/verify/check_schema.py +398 -0
  894. mobile_agent/verify/checks.py +614 -0
  895. mobile_agent/verify/cli.py +423 -0
  896. mobile_agent/verify/conditions.py +483 -0
  897. mobile_agent/verify/crashes.py +321 -0
  898. mobile_agent/verify/device_audit.py +246 -0
  899. mobile_agent/verify/dialogs.py +287 -0
  900. mobile_agent/verify/evidence.py +705 -0
  901. mobile_agent/verify/fixtures.py +305 -0
  902. mobile_agent/verify/flow.py +213 -0
  903. mobile_agent/verify/l10n.py +141 -0
  904. mobile_agent/verify/netcerts.py +67 -0
  905. mobile_agent/verify/netproxy.py +356 -0
  906. mobile_agent/verify/netrun.py +81 -0
  907. mobile_agent/verify/netshim.m +28 -0
  908. mobile_agent/verify/netshim.py +48 -0
  909. mobile_agent/verify/netstubs.py +132 -0
  910. mobile_agent/verify/overlay.py +174 -0
  911. mobile_agent/verify/perf.py +239 -0
  912. mobile_agent/verify/proof.py +192 -0
  913. mobile_agent/verify/replay_admission.py +48 -0
  914. mobile_agent/verify/report.py +952 -0
  915. mobile_agent/verify/run_cleanup.py +67 -0
  916. mobile_agent/verify/run_links.py +48 -0
  917. mobile_agent/verify/run_perf.py +44 -0
  918. mobile_agent/verify/run_secrets.py +39 -0
  919. mobile_agent/verify/runner.py +976 -0
  920. mobile_agent/verify/runs.py +57 -0
  921. mobile_agent/verify/screen_coverage.py +181 -0
  922. mobile_agent/verify/smart.py +106 -0
  923. mobile_agent/verify/snapshot_run.py +114 -0
  924. mobile_agent/verify/snapshots.py +306 -0
  925. mobile_agent/verify/sound.py +335 -0
  926. mobile_agent/verify/source_observation.py +130 -0
  927. mobile_agent/verify/system_surfaces/__init__.py +7 -0
  928. mobile_agent/verify/system_surfaces/play.py +106 -0
  929. mobile_agent/verify/system_surfaces/spec.py +139 -0
  930. mobile_agent/verify/system_surfaces/stage.py +138 -0
  931. mobile_agent/verify/system_surfaces/surfaces.py +121 -0
  932. mobile_agent/verify/tree.py +223 -0
  933. mobile_agent/verify/verdict.py +377 -0
  934. mobile_agent/verify/wire.py +392 -0
  935. mobile_agent/verify/wirestore.py +176 -0
  936. mobile_agent/waiting/__init__.py +1 -0
  937. mobile_agent/waiting/contacts.py +73 -0
  938. mobile_agent/waiting/drafts.py +136 -0
  939. mobile_agent/waiting/job.py +145 -0
  940. mobile_agent/waiting/messages.py +174 -0
  941. mobile_agent/waiting/rank.py +115 -0
  942. mobile_agent/waiting/register.py +26 -0
  943. mobile_agent/watch.py +502 -0
  944. mobile_agent/wda/__init__.py +2 -0
  945. mobile_agent/wda/frame_clock.py +646 -0
  946. mobile_agent/wda/frame_source.py +325 -0
  947. mobile_agent/wda/frame_store.py +115 -0
  948. mobile_agent/wda/gate.py +163 -0
  949. mobile_agent/wda/gestures.py +291 -0
  950. mobile_agent/wda/guards.py +94 -0
  951. mobile_agent/wda/identity.py +55 -0
  952. mobile_agent/wda/observe.py +884 -0
  953. mobile_agent/wda/patches.py +856 -0
  954. mobile_agent/wda/point_gestures.py +150 -0
  955. mobile_agent/wda/source.py +32 -0
  956. mobile_agent/wda/system_dialogs.py +571 -0
  957. mobile_agent/wda/text.py +694 -0
  958. mobile_agent/wda/timeouts.py +77 -0
  959. mobile_agent/wda/video.py +505 -0
  960. mobile_agent/wda/watchdog.py +243 -0
  961. mobile_agent/wireless/__init__.py +15 -0
  962. mobile_agent/wireless/api.py +354 -0
  963. mobile_agent/wireless/cli.py +287 -0
  964. mobile_agent/wireless/devicectl.py +229 -0
  965. mobile_agent/wireless/exposure.py +33 -0
  966. mobile_agent/wireless/mobster-wifi-pair.c +122 -0
  967. mobile_agent/wireless/monitor.py +332 -0
  968. mobile_agent/wireless/network.py +334 -0
  969. mobile_agent/wireless/onboarding.py +293 -0
  970. mobile_agent/wireless/pair.py +124 -0
  971. mobile_agent/wireless/probe.py +322 -0
  972. mobile_agent/wireless/register.py +8 -0
  973. mobile_agent/wireless/relay.py +179 -0
  974. mobile_agent/wireless/result.py +87 -0
  975. mobile_agent/wireless/store.py +85 -0
  976. mobile_agent/wireless/switch.py +77 -0
  977. mobile_agent/wireless/transport.py +118 -0
  978. mobile_agent/wireless/words.py +81 -0
  979. mobile_agent/wireless/xctestrun.py +83 -0
  980. mobile_agent/workflow_tools.py +59 -0
  981. mobile_agent/workflows/__init__.py +2 -0
  982. mobile_agent/workflows/brief.py +201 -0
  983. mobile_agent/workflows/cron.py +110 -0
  984. mobile_agent/workflows/health.py +257 -0
  985. mobile_agent/workflows/history.py +126 -0
  986. mobile_agent/workflows/records.py +442 -0
  987. mobile_agent/workflows/runs.py +1000 -0
  988. mobile_agent/workflows/scheduler.py +134 -0
  989. mobile_agent/workflows/service.py +973 -0
  990. mobile_agent/workflows/starters.py +285 -0
  991. mobile_agent/workflows/triggers.py +248 -0
  992. mobile_agent/xray/__init__.py +1 -0
  993. mobile_agent/xray/app_cli.py +116 -0
  994. mobile_agent/xray/cli.py +21 -0
  995. mobile_agent/xray/crawl.py +754 -0
  996. mobile_agent/xray/diff.py +171 -0
  997. mobile_agent/xray/diff_cli.py +43 -0
  998. mobile_agent/xray/job.py +184 -0
  999. mobile_agent/xray/paywall.py +188 -0
  1000. mobile_agent/xray/policy.py +305 -0
  1001. mobile_agent/xray/register.py +43 -0
  1002. mobile_agent/xray/report.py +283 -0
  1003. mobile_agent/xray/screens.py +117 -0
  1004. mobster_cli-0.4.3.dist-info/METADATA +93 -0
  1005. mobster_cli-0.4.3.dist-info/RECORD +1009 -0
  1006. mobster_cli-0.4.3.dist-info/WHEEL +5 -0
  1007. mobster_cli-0.4.3.dist-info/entry_points.txt +3 -0
  1008. mobster_cli-0.4.3.dist-info/licenses/LICENSE +21 -0
  1009. mobster_cli-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,975 @@
1
+ """Conversations in one Runtime: the store, message routing, the run listener, the bus and the thread sink."""
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import re
7
+ import threading
8
+
9
+ from .. import harness_api, secret_filter
10
+ from ..api_errors import APIError, nothing_started
11
+ from ..service_support import ServiceRegistry
12
+ from . import store as thread_store
13
+ from .store import (
14
+ CORE_KINDS,
15
+ KIND,
16
+ MAX_RULES,
17
+ MAX_SINK_ITEM_BYTES,
18
+ RULES_CHARS,
19
+ ThreadFull,
20
+ ThreadMissing,
21
+ ThreadStore,
22
+ failed_request,
23
+ public_item,
24
+ request_result,
25
+ request_unknown,
26
+ )
27
+ from .summary import context_of, group_fields, referents_of, result_of
28
+
29
+ log = logging.getLogger("mobster.threads")
30
+
31
+ VIA = ("composer", "voice", "cli", "tui", "mcp", "remote")
32
+ # Where a request came from (X-Mobster-Origin) -> the item's "via" and the steering source (S8). "api" (no header,
33
+ # a script) steers as the terminal does: never as the Mac app.
34
+ ORIGIN_VIA = {"app": "composer", "voice": "voice", "tui": "tui", "cli": "cli", "mcp": "mcp", "api": "cli",
35
+ "remote": "remote"}
36
+ ORIGIN_SOURCE = {"app": "app", "tui": "tui", "cli": "cli", "mcp": "mcp", "api": "cli", "remote": "remote"}
37
+ START_CHARS = 4000
38
+ STEER_CHARS = 2000
39
+ ANSWER_CHARS = 500
40
+ UNREAD_NOTE = "Mobster finished before reading this."
41
+ STEER_UNSUPPORTED = "This task can't take messages while it works. Wait for it to finish, or stop it."
42
+ ARCHIVED = "This conversation is archived. Restore it to continue."
43
+ # run.APPROVAL_PENDING, the sentence Run.steer refuses with (a test keeps them equal).
44
+ APPROVAL_PENDING = "Choose Send or Don't send above, or say what to do instead there."
45
+ # run.Run.steer's sentence for a message from a surface the task doesn't hear (a test keeps them equal).
46
+ STEER_NOT_ALLOWED = "This task takes messages only where it was started."
47
+ STEER_FILES ="Mobster reads files only when a task starts. Send them after this task, or stop it first."
48
+ REMOTE_FILES = "Files can't be sent through Mobster Remote."
49
+ STEPS_RUNNING = ("This workflow is running several steps at once. Send your message when they finish.")
50
+ GROUP_RUNNING = ("This task is running on several devices. Send your message when they finish, or answer each device’s "
51
+ "question on its tab.")
52
+ FILES_MISSING = "Attaching files isn't in this build yet."
53
+ ACCOUNTS_MISSING = "Profiles aren't available right now. Restart Mobster, then try again."
54
+ OUTLETS_MISSING = "Sending results is part of Mobster for Mac. Open it to send a result to another tool."
55
+
56
+ # A rule in a message: a sentence with "don't", "never", "only look" or "without".
57
+ _PROHIBITION = re.compile(r"[^.!?]*\b(?:do not|don't|never|only look|without)\b[^.!?]*[.!?]", re.I)
58
+ _services = ServiceRegistry()
59
+
60
+
61
+ def service_for(runtime):
62
+ """The live ThreadService of ``runtime``, or None (the track is off, or the runtime closed)."""
63
+ return _services.get(runtime)
64
+
65
+
66
+ def services():
67
+ return _services.all()
68
+
69
+
70
+ def start(runtime):
71
+ """register_service's start: a ThreadService on the runtime's journal."""
72
+ service = ThreadService(runtime)
73
+ _services.add(runtime, service)
74
+ service.reconcile()
75
+
76
+
77
+ def close(runtime):
78
+ service = _services.pop(runtime)
79
+ if service is not None:
80
+ service.closed = True
81
+
82
+
83
+ def clean_text(text, limit, what="A message"):
84
+ if not isinstance(text, str):
85
+ raise ValueError(f"{what} is text")
86
+ text = text.strip()
87
+ if not text:
88
+ raise ValueError("Write a message first")
89
+ if len(text) > limit:
90
+ raise ValueError(f"{what} can be at most {limit:,} characters")
91
+ return text
92
+
93
+
94
+ def may_message(run, source, remote_grant=None):
95
+ """APIError 403 steer_not_allowed when ``source`` may not send ``run`` a message: Run.steer's rule (S5), applied
96
+ to answers too, which reach the run through Run.answer_approval instead."""
97
+ if source not in harness_api.steer_sources(getattr(run, "origin", None)):
98
+ raise APIError(STEER_NOT_ALLOWED, 403, "steer_not_allowed")
99
+ if source == "remote":
100
+ extras = run.extras if isinstance(getattr(run, "extras", None), dict) else {}
101
+ if not remote_grant or extras.get("remoteGrant") != remote_grant:
102
+ raise APIError(STEER_NOT_ALLOWED, 403, "steer_not_allowed")
103
+
104
+
105
+ def fingerprint(value):
106
+ return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=True, allow_nan=False).encode()).hexdigest()
107
+
108
+
109
+ def run_request_key(thread_id, key):
110
+ return f"thread-{thread_id}-{fingerprint(key)}" if key else None
111
+
112
+
113
+ def prohibitions(text):
114
+ """The rules in a message ("don't…", "never…", "only look…", "without…"), copied by code."""
115
+ text = " ".join(str(text or "").split())
116
+ if text and text[-1] not in ".!?":
117
+ text += "." # a chat message often ends without a full stop; the rule reads to the end of a sentence
118
+ return [m.group(0).strip() for m in _PROHIBITION.finditer(text) if m.group(0).strip()]
119
+
120
+
121
+ def merge_rules(rules, text):
122
+ """``rules`` with the new ones in ``text`` added; the newest kept within MAX_RULES and RULES_CHARS."""
123
+ out = list(rules or [])
124
+ for rule in prohibitions(text):
125
+ rule = rule[:300]
126
+ if rule.casefold() in (r.casefold() for r in out):
127
+ continue
128
+ out.append(rule)
129
+ while out and (len(out) > MAX_RULES or sum(len(r) + 3 for r in out) > RULES_CHARS):
130
+ out.pop(0)
131
+ return out
132
+
133
+
134
+ class ThreadService:
135
+ def __init__(self, runtime):
136
+ self.runtime = runtime
137
+ self.store = ThreadStore(runtime.journal)
138
+ self.closed = False
139
+ # Serializes routing per process: a message's state check and its effect are one step.
140
+ self.lock = threading.RLock()
141
+ self.runs = {} # run id -> thread id, for the runs this process started in a thread
142
+ self.last_app = {} # run id -> the last app name its events named
143
+
144
+ def publish(self, topic, event):
145
+ publish = getattr(self.runtime, "publish", None)
146
+ if callable(publish):
147
+ publish(topic, event)
148
+
149
+ def published_thread(self, thread, event):
150
+ self.publish("threads", {"event": event, "thread": thread})
151
+
152
+ def published_item(self, thread_id, item, event="thread_item"):
153
+ self.publish(f"thread:{thread_id}", {"event": event, "item": public_item(item)})
154
+
155
+ def thread(self, thread_id):
156
+ thread = self.store.get(thread_id)
157
+ if thread is None:
158
+ raise ThreadMissing()
159
+ return thread
160
+
161
+ def create_thread(self, title=None, device=None):
162
+ device_id = device_name = None
163
+ if device is not None:
164
+ phone = self.resolve(device)
165
+ device_id, device_name = getattr(phone, "id", None), getattr(phone, "name", None)
166
+ thread = self.store.create_thread(title=title, device=device_id, device_name=device_name)
167
+ self.published_thread(thread, "thread_created")
168
+ return thread
169
+
170
+ def resolve(self, device):
171
+ if not isinstance(device, str) or not 1 <= len(device) <= 128:
172
+ raise ValueError("device must be a device's id, UDID or name")
173
+ resolve = getattr(self.runtime, "resolve_device", None)
174
+ if not callable(resolve) or getattr(self.runtime, "fleet", None) is None:
175
+ raise ValueError("This Mobster runs every task on its own device; leave device out")
176
+ return resolve(device)
177
+
178
+ def edit_thread(self, thread_id, changes):
179
+ self.thread(thread_id)
180
+ clean = {}
181
+ if "title" in changes:
182
+ title = changes["title"]
183
+ if not isinstance(title, str) or not 1 <= len(" ".join(title.split())) <= thread_store.TITLE_CHARS:
184
+ raise ValueError("A title is 1 to 80 characters")
185
+ clean["title"] = " ".join(title.split())
186
+ for flag in ("archived", "pinned"):
187
+ if flag in changes:
188
+ if type(changes[flag]) is not bool:
189
+ raise ValueError(f"{flag} is true or false")
190
+ clean[flag] = changes[flag]
191
+ thread = self.store.update_thread(thread_id, clean)
192
+ self.published_thread(thread, "thread_updated")
193
+ return thread
194
+
195
+ def active_run(self, thread):
196
+ """The thread's run that is still going, or None."""
197
+ runtime = self.runtime
198
+ with runtime.lock:
199
+ candidates = [runtime.runs.get(thread.get("lastRunId"))] + [
200
+ run for run in runtime.runs.values()
201
+ if isinstance(run.extras, dict) and run.extras.get("threadId") == thread["id"]]
202
+ for run in candidates:
203
+ if run is not None and run.finished_at is None:
204
+ return run
205
+ return None
206
+
207
+ def delete_thread(self, thread_id, runs="delete"):
208
+ if runs not in ("delete", "keep"):
209
+ raise ValueError("runs is delete or keep")
210
+ with self.lock:
211
+ thread = self.thread(thread_id)
212
+ if self.active_run(thread) is not None:
213
+ raise APIError("Stop the task in this conversation before deleting it.", 409, "run_active")
214
+ run_ids = self.store.run_ids(thread_id)
215
+ deleted_runs = 0
216
+ if runs == "delete":
217
+ for run_id in run_ids:
218
+ if run_id in self.runtime.runs:
219
+ try:
220
+ self.runtime.delete_run(run_id)
221
+ deleted_runs += 1
222
+ except APIError:
223
+ pass # it started meanwhile: kept, and the thread still goes
224
+ self.store.delete_thread(thread_id)
225
+ self.published_thread({"id": thread_id}, "thread_deleted")
226
+ return {"deleted": True, "runsDeleted": deleted_runs}
227
+
228
+ def message(self, thread_id, body, *, origin="api", key=None, remote_grant=None):
229
+ """Route one message (SPEC §3.2 feature 2)."""
230
+ if origin == "remote" and not remote_grant:
231
+ raise ValueError("A message through Mobster Remote names its grant")
232
+ fields = {"text", "attachmentIds", "appId", "engine", "outputFormat", "outputSchema", "device", "phones",
233
+ "profiles", "mode",
234
+ "via", "account", "longform", "pinnedApps", "sendTo",
235
+ "permissionMode", "queue"}
236
+ if not isinstance(body, dict) or set(body) - fields:
237
+ raise ValueError(
238
+ "A message has text, and optionally attachmentIds, appId, pinnedApps, engine, outputFormat, "
239
+ "outputSchema, device or phones, account, mode (auto or new_task), via, longform, sendTo "
240
+ "and permissionMode")
241
+ if body.get("device") is not None and body.get("phones") is not None:
242
+ raise ValueError("Name the phones in phones, or one phone in device, not both")
243
+ if body.get("account") is not None and body.get("phones") is not None:
244
+ raise ValueError("A profile runs on its own iPhone: leave phones out")
245
+ if body.get("profiles") is not None and any(body.get(name) is not None for name in ("phones", "device",
246
+ "account")):
247
+ raise ValueError("Run on several profiles with profiles alone: each runs on its own iPhone")
248
+ mode = body.get("mode", "auto")
249
+ if mode not in ("auto", "new_task"):
250
+ raise ValueError("mode is auto or new_task")
251
+ via = self.via(body.get("via"), origin)
252
+ attachments = body.get("attachmentIds")
253
+ if attachments is not None and (not isinstance(attachments, list) or
254
+ not all(isinstance(a, str) for a in attachments)):
255
+ raise ValueError("attachmentIds is a list of attachment ids")
256
+ with self.lock:
257
+ if key is not None:
258
+ saved = self.store.request(key)
259
+ if saved is not None:
260
+ if saved[0] != thread_id or saved[1] != fingerprint(body):
261
+ raise APIError("This request key belongs to a different message", 409,
262
+ "idempotency_conflict")
263
+ response = request_result(saved[2])
264
+ return response["status"], {**response["response"], "replayed": True}
265
+ thread = self.thread(thread_id)
266
+ if thread.get("archived"):
267
+ raise APIError(ARCHIVED, 409, "thread_archived")
268
+ if key is not None:
269
+ self.store.remember(key, thread_id, fingerprint(body), None)
270
+ try:
271
+ run = self.active_run(thread) if mode == "auto" else None
272
+ routed = self.to_run(thread, run, body, via, origin, attachments, remote_grant) if run else None
273
+ if routed is None:
274
+ routed = self.start(thread, body, via, origin, attachments, key, remote_grant)
275
+ except (APIError, ValueError) as error:
276
+ if key is not None and not (isinstance(error, APIError) and error.code == "request_outcome_unknown"):
277
+ self.store.remember(key, thread_id, fingerprint(body), failed_request(error))
278
+ raise
279
+ if key is not None:
280
+ self.store.remember(key, thread_id, fingerprint(body), {"status": routed[0], "response": routed[1]})
281
+ return routed
282
+
283
+ @staticmethod
284
+ def via(value, origin):
285
+ """The item's "via": the request's surface (its X-Mobster-Origin)."""
286
+ default = ORIGIN_VIA.get(origin, "cli")
287
+ if value is None:
288
+ return default
289
+ if value not in VIA:
290
+ raise ValueError("via is composer, voice, cli, tui, mcp or remote")
291
+ return value if origin == "app" and value in ("composer", "voice") else default
292
+
293
+ def to_run(self, thread, run, body, via, origin, attachments, remote_grant=None):
294
+ """Route a message to the thread's running task. None when it finished meanwhile (start a new one)."""
295
+ if getattr(run, "group_id", None):
296
+ # A task on several phones takes no messages while it runs: each phone's run is its own, and one message
297
+ # can't steer them all the same way. Questions are answered on their phone's tab.
298
+ raise APIError(GROUP_RUNNING, 409, "group_running", groupId=run.group_id)
299
+ with self.runtime.lock:
300
+ going = sum(1 for other in self.runtime.runs.values() if isinstance(other.extras, dict)
301
+ and other.extras.get("threadId") == thread["id"] and other.finished_at is None)
302
+ if going > 1:
303
+ # A workflow running steps side by side: one message can't steer them all the same way.
304
+ raise APIError(STEPS_RUNNING, 409, "steps_running")
305
+ pending = run.approval
306
+ if origin == "remote":
307
+ # Through Mobster Remote only the app that started the task reaches it, and never with files.
308
+ may_message(run, "remote", remote_grant)
309
+ if attachments:
310
+ raise APIError(REMOTE_FILES, 409, "steer_attachments")
311
+ if pending is not None and pending.get("kind") == "clarify":
312
+ # An answer is a message to the task, so it follows Run.steer's rule for who may send one: a task
313
+ # started over MCP hears only MCP and the Mac app, and any other task never hears MCP.
314
+ may_message(run, self.source(body, origin), remote_grant)
315
+ text = clean_text(body["text"] if "text" in body else None, ANSWER_CHARS, "An answer")
316
+ # The question is in the thread before its answer, even when the listener hasn't caught up yet.
317
+ self.clarify_item(thread["id"], run, {"approval_id": pending["id"], "label": pending.get("label")},
318
+ pending=pending)
319
+ if not run.answer_approval(pending["id"], True, answer=text):
320
+ if run.finished_at is not None:
321
+ return None
322
+ raise APIError("Mobster's question is no longer waiting.", 409, "approval_not_pending")
323
+ try:
324
+ item = self.add_user(thread["id"], text, via, "answer", run.id, [], approvalId=pending["id"])
325
+ self.answered(thread["id"], run.id, pending["id"], text)
326
+ except Exception:
327
+ raise request_unknown() from None
328
+ return 202, {"item": public_item(item), "routed": "answer"}
329
+ if pending is not None:
330
+ # An approval waits: refused as Run.steer refuses it on every surface, and nothing is queued.
331
+ raise APIError(APPROVAL_PENDING, 409, "approval_pending", approvalId=pending.get("id"))
332
+ if not run.steerable:
333
+ from .. import ai_plans
334
+ if ai_plans.is_agent(run.engine):
335
+ from ..ai_plans import agents
336
+ raise APIError(f"{agents.get(run.engine).name} can't take messages while it works. Wait for it to "
337
+ "finish, or stop it.", 409, "steer_unsupported")
338
+ raise APIError(STEER_UNSUPPORTED, 409, "steer_unsupported")
339
+ if attachments:
340
+ raise APIError(STEER_FILES, 409, "steer_attachments")
341
+ text = clean_text(body.get("text"), STEER_CHARS)
342
+ try:
343
+ message = run.steer(text, source=self.source(body, origin))
344
+ except APIError as error:
345
+ if error.code == "run_not_active":
346
+ return None
347
+ raise
348
+ try:
349
+ item = self.add_steer(thread["id"], run.id, message["id"], message["text"], via)
350
+ except Exception:
351
+ raise request_unknown() from None
352
+ return 202, {"item": public_item(item), "routed": "steer"}
353
+
354
+ @staticmethod
355
+ def source(body, origin):
356
+ source = ORIGIN_SOURCE.get(origin, "cli")
357
+ return "voice" if source == "app" and body.get("via") == "voice" else source
358
+
359
+ def start(self, thread, body, via, origin, attachments, key, remote_grant=None):
360
+ """A new task in the thread."""
361
+ if origin == "remote" and attachments:
362
+ raise APIError(REMOTE_FILES, 409, "steer_attachments")
363
+ text = clean_text(body.get("text"), START_CHARS)
364
+ count, _size = self.store.usage(thread["id"])
365
+ if count + 2 + thread_store.RESERVE > thread_store.MAX_ITEMS:
366
+ raise ThreadFull()
367
+ if not attachments and origin in harness_api.INTERACTIVE and body.get("mode", "auto") == "auto":
368
+ offered = self.offer_to_remember(thread, text, via, body)
369
+ if offered is not None:
370
+ return offered
371
+ extras = {"threadId": thread["id"], "via": via}
372
+ if origin == "remote":
373
+ extras["remoteGrant"] = remote_grant
374
+ if origin in harness_api.INTERACTIVE and "askUser" in harness_api.run_fields():
375
+ # Every surface that shows a conversation shows its questions (a clarify item the Mac app, `mobster
376
+ # chat` and the terminal UI answer inline), so a task a person started here may ask one (harness's
377
+ # ASK_USER). MCP and the plain API never: nobody there is watching.
378
+ extras["askUser"] = True
379
+ if body.get("permissionMode") is not None:
380
+ # The composer's per-task mode (track permissions); asking less than Settings needs the Mac app's session,
381
+ # which the server checked before this.
382
+ if "permissionMode" not in harness_api.run_fields():
383
+ raise APIError("This Mobster can't set a task's permission mode. Update Mobster.", 409,
384
+ "permissions_unavailable")
385
+ extras["permissionMode"] = body["permissionMode"]
386
+ if attachments:
387
+ if "attachmentIds" not in harness_api.run_fields():
388
+ raise APIError(FILES_MISSING, 409, "attachments_unavailable")
389
+ extras["attachmentIds"] = attachments
390
+ account = self.account_for(thread, body)
391
+ if account is not None:
392
+ extras["account"] = account
393
+ if body.get("sendTo") is not None:
394
+ # Where the new task's result goes when it finishes (track outlets): the run field, as POST /api/runs.
395
+ if "sendTo" not in harness_api.run_fields():
396
+ raise APIError(OUTLETS_MISSING, 409, "outlets_unavailable")
397
+ extras["sendTo"] = body["sendTo"]
398
+ if body.get("longform") is not None:
399
+ # A long task started from the conversation (its plan card's Start): the run field, as POST /api/runs.
400
+ if "longform" not in harness_api.run_fields():
401
+ raise APIError("Long tasks aren't in this build.", 409, "longform_unavailable")
402
+ extras["longform"] = body["longform"]
403
+ device = body.get("device")
404
+ if device is not None and not isinstance(device, str):
405
+ raise ValueError("device must be a device's id, UDID or name")
406
+ if body.get("profiles") is not None and getattr(self.runtime, "fanout", None) is not None:
407
+ extras.pop("account", None) # each member is its own profile (fanout.start_profiles)
408
+ return self.start_group(thread, body, text, via, origin, extras, None, key, profiles=body["profiles"])
409
+ phones = body.get("phones")
410
+ if phones is None and device is None and account is None and (thread.get("group") or {}).get("phones"):
411
+ phones = list(thread["group"]["phones"]) # the next task runs where the last one did: on every phone
412
+ if phones is not None and getattr(self.runtime, "fanout", None) is not None:
413
+ extras.pop("account", None) # each phone acts as itself; an account runs on its own iPhone
414
+ return self.start_group(thread, body, text, via, origin, extras, phones, key)
415
+ if account is not None and "device" not in body:
416
+ device = None # the account's phone (accounts/routing.py), not the conversation's last one
417
+ elif device is None and thread.get("device") and getattr(self.runtime, "pool", None) is None:
418
+ device = thread["device"]
419
+ kwargs = {}
420
+ if device is not None and getattr(self.runtime, "fleet", None) is not None and \
421
+ getattr(self.runtime, "pool", None) is None:
422
+ kwargs["device"] = device
423
+ run_key = run_request_key(thread["id"], key)
424
+ if body.get("pinnedApps") is not None:
425
+ kwargs["pinned_apps"] = body["pinnedApps"]
426
+ if key and self.runtime.journal.lookup(f"thread-{thread['id']}-{key}"[:128]) is not None:
427
+ # A previous build may have admitted this key before it saved the conversation response.
428
+ raise request_unknown()
429
+ try:
430
+ created = self.runtime.create(body.get("appId"), text, "live", run_key,
431
+ output_schema=body.get("outputSchema"),
432
+ output_format=body.get("outputFormat", "auto"), engine=body.get("engine"),
433
+ extras=extras, origin=origin if origin in harness_api.ORIGINS else "api",
434
+ queue=body.get("queue", True) is not False, **kwargs)
435
+ except (APIError, ValueError) as error:
436
+ if run_key is not None and self.runtime.journal.lookup(run_key) is not None:
437
+ raise request_unknown() from None
438
+ if run_key is not None and isinstance(error, APIError):
439
+ raise nothing_started(error) from None # no task under this request's key: none was recorded or sent
440
+ raise
441
+ run = created[0] if isinstance(created, tuple) else created
442
+ try:
443
+ user, _run_item = self.exchange(thread["id"], run, text=text, via=via)
444
+ return 201, {"item": public_item(user) if user else None, "run": run.public(include_events=False)}
445
+ except Exception:
446
+ raise request_unknown() from None
447
+
448
+ def account_for(self, thread, body):
449
+ """The account a new task acts as (track accounts' ``account`` run field): the one the message names (null:
450
+ none), else the one the conversation's last task acted as, so "now reply to the next one" stays @acme."""
451
+ if "account" in body:
452
+ if body["account"] is not None and "account" not in harness_api.run_fields():
453
+ raise APIError(ACCOUNTS_MISSING, 409, "accounts_unavailable")
454
+ return body["account"]
455
+ if "account" not in harness_api.run_fields():
456
+ return None
457
+ last = self.runtime.runs.get(thread.get("lastRunId") or "")
458
+ previous = (getattr(last, "extras", None) or {}).get("account")
459
+ return previous["id"] if isinstance(previous, dict) and isinstance(previous.get("id"), str) else None
460
+
461
+ def start_group(self, thread, body, text, via, origin, extras, phones, key, profiles=None):
462
+ """A new task in the thread on several phones at once (fanout.py): one user item, one group item that
463
+ follows the group, and each phone's run item."""
464
+ if key and self.runtime.fanout.has_request(f"thread-{thread['id']}-{key}"[:128]):
465
+ raise request_unknown()
466
+ run_key = run_request_key(thread["id"], key)
467
+ try:
468
+ group, _replayed = (self.runtime.start_profiles if profiles is not None else self.runtime.start_group)(
469
+ profiles if profiles is not None else phones, body.get("appId"), text, idempotency_key=run_key,
470
+ fingerprint=fingerprint(body), thread_id=thread["id"], output_schema=body.get("outputSchema"),
471
+ output_format=body.get("outputFormat", "auto"), engine=body.get("engine"), extras=extras,
472
+ origin=origin if origin in harness_api.ORIGINS else "api")
473
+ except (APIError, ValueError) as error:
474
+ if run_key is not None and self.runtime.fanout.has_request(run_key):
475
+ raise request_unknown() from None
476
+ if run_key is not None and isinstance(error, APIError) and "refused" not in error.details:
477
+ # A group takes its key before its first phone's run (fanout._open), so without it nothing was recorded.
478
+ # The 409 for a group no phone started ("refused" lists each phone) isn't marked: a phone's run may
479
+ # have been recorded, then interrupted (start_failed). It is a 4xx refusal all the same.
480
+ raise nothing_started(error) from None
481
+ raise
482
+ try:
483
+ user, _group_item = self.exchange_group(thread["id"], group, text=text, via=via)
484
+ first = None
485
+ for member in group["members"]:
486
+ run = self.runtime.runs.get(member.get("runId")) if member.get("runId") else None
487
+ if run is not None:
488
+ self.exchange(thread["id"], run)
489
+ first = first or run
490
+ public = self.runtime.fanout.public(group)
491
+ return 201, {"item": public_item(user) if user else None,
492
+ "run": first.public(include_events=False) if first is not None else None, "group": public}
493
+ except Exception:
494
+ raise request_unknown() from None
495
+
496
+ def exchange_group(self, thread_id, group, *, text, via):
497
+ """The user item and the group item of a task started on several phones; idempotent by the group's id."""
498
+ public = self.runtime.fanout.public(group)
499
+ attachments = self.attachment_refs(list(((group.get("create") or {}).get("extras") or {}).get("attachmentIds")
500
+ or []))
501
+ user = {"kind": "user", "text": text, "via": via, "attachments": attachments, "routed": "new_run",
502
+ "runId": None, "groupId": group["id"]}
503
+ item = {"kind": "group", **group_fields(public)}
504
+ with self.lock:
505
+ try:
506
+ added = self.store.append(thread_id, [user, item], unless=lambda found: any(
507
+ i.get("kind") == "group" and i.get("groupId") == group["id"] for i in found))
508
+ except ThreadFull:
509
+ log.warning("thread %s is full; group %s isn't in it", thread_id, group["id"])
510
+ return None, None
511
+ if added is None:
512
+ found = self.store.find(thread_id, match=lambda i: i.get("groupId") == group["id"]
513
+ and i.get("kind") in ("user", "group"))
514
+ return (next((i for i in found if i["kind"] == "user"), None),
515
+ next((i for i in found if i["kind"] == "group"), None))
516
+ by_profile = any(member.get("profile") for member in public["members"])
517
+ # A group by profile doesn't make the thread's next task run on those phones: the phones are the
518
+ # profiles', not a choice of the person's.
519
+ phones = [] if by_profile else [member["device"] for member in public["members"]]
520
+ names = {} if by_profile else {member["device"]: member.get("deviceName") for member in public["members"]}
521
+ first = next((member["runId"] for member in public["members"] if member.get("runId")), None)
522
+ updated = self.store.update_thread(thread_id, {
523
+ "status": "waiting" if public["state"] == "waiting" else "running", "lastRunId": first,
524
+ "lastGroupId": group["id"], "group": {"id": group["id"], "phones": phones, "names": names},
525
+ "device": None, "deviceName": None})
526
+ self.add_rules(thread_id, text)
527
+ for stored in added:
528
+ self.published_item(thread_id, stored)
529
+ if updated is not None:
530
+ self.published_thread(updated, "thread_updated")
531
+ return added[0], added[1]
532
+
533
+ def on_group(self, group, event):
534
+ """fanout's listener: the group's item follows it (a phone started, was given up, finished)."""
535
+ thread_id = group.get("threadId")
536
+ if not thread_id or self.store.get(thread_id) is None:
537
+ return
538
+ with self.lock:
539
+ found = self.store.find(thread_id, kind="group", match=lambda i: i.get("groupId") == group["id"])
540
+ if not found:
541
+ return
542
+ try:
543
+ updated = self.store.update_item(thread_id, found[0]["id"], group_fields(group), kinds={"group"})
544
+ except ThreadFull:
545
+ updated = self.store.update_item(thread_id, found[0]["id"],
546
+ {k: v for k, v in group_fields(group).items() if k != "summary"},
547
+ kinds={"group"})
548
+ thread = self.store.get(thread_id) or {}
549
+ current = thread.get("lastGroupId") == group["id"]
550
+ status = {"waiting": "waiting", "running": "running", "held": "running"}.get(group["state"], "idle")
551
+ changes = {"status": status} if current and thread.get("status") != status else {}
552
+ updated_thread = self.store.update_thread(thread_id, changes) if changes else None
553
+ if updated is not None:
554
+ self.published_item(thread_id, updated, "thread_item_updated")
555
+ if updated_thread is not None:
556
+ self.published_thread(updated_thread, "thread_updated")
557
+
558
+ def offer_to_remember(self, thread, text, via, body):
559
+ """"Remember that my gym is the one on 5th Street" is a note for Mobster, not a task for the phone."""
560
+ try:
561
+ from ..memory import extract
562
+ from ..memory.listener import publish, thread_item
563
+ from ..memory.store import default_store, normalize
564
+ except ImportError:
565
+ return None
566
+ found = extract.remember_only(text)
567
+ if not found:
568
+ return None
569
+ if body.get("profiles") is not None:
570
+ raise APIError("Choose one saved profile for a thing to remember, or choose No profile to remember it "
571
+ "for every task.", 409, "memory_profile_ambiguous")
572
+ account = self.account_for(thread, body)
573
+ scope = "global"
574
+ if account is not None:
575
+ from ..accounts.routing import validate
576
+ scope = f"profile:{validate(account, self.runtime)['id']}"
577
+ store = default_store()
578
+ if store is None:
579
+ return None
580
+ if any(secret_filter.is_secret(candidate.text) for candidate in found):
581
+ raise APIError("Mobster doesn't remember passwords, codes or card numbers.", 400, "memory_secret")
582
+ known = {normalize(fact.get("text") or "") for fact in store.all_facts() if fact.get("scope") == scope}
583
+ if all(normalize(candidate.text) in known for candidate in found):
584
+ raise APIError("Mobster already remembers this.", 409, "memory_duplicate")
585
+ user = self.add_user(thread["id"], text, via, "remember", None, [])
586
+ proposals = []
587
+ for candidate in found:
588
+ proposal = store.propose(candidate.text, scope, thread_id=thread["id"], pin=candidate.pin)
589
+ if proposal is None:
590
+ continue
591
+ proposals.append(proposal)
592
+ item = self.add_other(thread["id"], thread_item(proposal))
593
+ if isinstance(item, dict) and item.get("id"):
594
+ store.set_proposal_item(proposal["id"], item["id"])
595
+ publish({"event": "proposal_created", "proposal": proposal})
596
+ updated = self.store.update_thread(thread["id"], {})
597
+ if updated is not None:
598
+ self.published_thread(updated, "thread_updated")
599
+ return 201, {"item": public_item(user), "run": None, "routed": "remember",
600
+ "proposals": [{"id": p["id"], "text": p["text"], "scope": p["scope"], "pin": p["pin"]}
601
+ for p in proposals]}
602
+
603
+ def add_other(self, thread_id, item):
604
+ """Another track's item in a thread (a suggestion to remember), published as harness_api's sink does."""
605
+ try:
606
+ stored = self.store.append(thread_id, [item])[0]
607
+ except Exception: # noqa: BLE001 -- a thread that can't take the item never breaks the message
608
+ log.exception("could not add a %s item", item.get("kind"))
609
+ return None
610
+ self.published_item(thread_id, stored)
611
+ return stored
612
+
613
+ def attachment_refs(self, ids):
614
+ """AttachmentRef for each id (files track's table, SPEC §3.4); a bare ref when it can't be read."""
615
+ if not ids:
616
+ return []
617
+ refs = {}
618
+ try:
619
+ with self.runtime.journal.lock:
620
+ rows = self.runtime.journal.connection.execute(
621
+ f"SELECT id, data FROM attachments WHERE id IN ({','.join('?' * len(ids))})", list(ids)).fetchall()
622
+ for ident, data in rows:
623
+ value = json.loads(data)
624
+ refs[ident] = {"id": ident, "name": str(value.get("name") or ""), "mime": str(value.get("mime") or ""),
625
+ "bytes": int(value.get("bytes") or 0), "kind": str(value.get("kind") or "")}
626
+ except Exception: # noqa: BLE001 -- no files track, or another shape: bare refs
627
+ pass
628
+ return [refs.get(i) or {"id": i, "name": "", "mime": "", "bytes": 0, "kind": ""} for i in ids]
629
+
630
+ def add_rules(self, thread_id, text):
631
+ thread = self.store.get(thread_id)
632
+ if thread is None:
633
+ return
634
+ rules = merge_rules(thread.get("rules"), text)
635
+ if rules != (thread.get("rules") or []):
636
+ updated = self.store.update_thread(thread_id, {"rules": rules}, touch=False)
637
+ if updated is not None:
638
+ self.published_thread(updated, "thread_updated")
639
+
640
+ def add_user(self, thread_id, text, via, routed, run_id, attachments, **extra):
641
+ item = {"kind": "user", "text": text, "via": via, "attachments": attachments, "routed": routed,
642
+ "runId": run_id, **extra}
643
+ stored = self.store.append(thread_id, [item])[0]
644
+ self.add_rules(thread_id, text)
645
+ self.published_item(thread_id, stored)
646
+ return stored
647
+
648
+ def add_steer(self, thread_id, run_id, message_id, text, via):
649
+ """The user item for a message sent to a running task (idempotent by the message's id)."""
650
+ added = self.store.append(thread_id, [{"kind": "user", "text": text, "via": via, "attachments": [],
651
+ "routed": "steer", "runId": run_id, "messageId": message_id}],
652
+ unless=lambda found: any(i.get("messageId") == message_id for i in found))
653
+ if added is None:
654
+ return self.store.find(thread_id, kind="user", run_id=run_id,
655
+ match=lambda i: i.get("messageId") == message_id)[0]
656
+ self.add_rules(thread_id, text)
657
+ self.published_item(thread_id, added[0])
658
+ return added[0]
659
+
660
+ def exchange(self, thread_id, run, *, text=None, via=None):
661
+ """The user item and the run item of a task started in the thread; idempotent by the run's id (the route
662
+ and the listener's on_created both call it)."""
663
+ extras = run.extras if isinstance(run.extras, dict) else {}
664
+ if getattr(run, "group_id", None):
665
+ return None, self.exchange_member(thread_id, run)
666
+ via = via or extras.get("via") or ORIGIN_VIA.get(run.origin, "cli")
667
+ attachments = self.attachment_refs(list(extras.get("attachmentIds") or []))
668
+ user = {"kind": "user", "text": text if text is not None else run.goal, "via": via,
669
+ "attachments": attachments, "routed": "new_run", "runId": run.id}
670
+ start_app = run.app.get("name") if isinstance(run.app, dict) and run.app.get("bundleId") else None
671
+ run_item = {"kind": "run", "runId": run.id, "goal": run.goal, "appName": start_app, "engine": run.engine,
672
+ "device": run.device_id}
673
+ with self.lock:
674
+ self.runs[run.id] = thread_id
675
+ try:
676
+ added = self.store.append(
677
+ thread_id, [user, run_item],
678
+ unless=lambda found: any(i.get("kind") == "run" and i.get("runId") == run.id for i in found))
679
+ except ThreadFull:
680
+ log.warning("thread %s is full; run %s isn't in it", thread_id, run.id)
681
+ return None, None
682
+ if added is None:
683
+ found = self.store.find(thread_id, run_id=run.id)
684
+ return (next((i for i in found if i["kind"] == "user" and i.get("routed") == "new_run"), None),
685
+ next((i for i in found if i["kind"] == "run"), None))
686
+ changes = {"status": "running", "lastRunId": run.id}
687
+ thread = self.store.get(thread_id) or {}
688
+ if run.device_id and (not thread.get("device") or thread.get("device") != run.device_id):
689
+ changes.update(device=run.device_id, deviceName=run.device_name)
690
+ if thread.get("group"):
691
+ changes.update(group=None, lastGroupId=None) # the newest task runs on one phone
692
+ updated = self.store.update_thread(thread_id, changes)
693
+ self.add_rules(thread_id, user["text"])
694
+ for item in added:
695
+ self.published_item(thread_id, item)
696
+ if updated is not None:
697
+ self.published_thread(self.store.get(thread_id) or updated, "thread_updated")
698
+ return added[0], added[1]
699
+
700
+ def exchange_member(self, thread_id, run):
701
+ """One phone's run item in a task started on several phones (the user and group items are the group's)."""
702
+ start_app = run.app.get("name") if isinstance(run.app, dict) and run.app.get("bundleId") else None
703
+ item = {"kind": "run", "runId": run.id, "goal": run.goal, "appName": start_app, "engine": run.engine,
704
+ "device": run.device_id, "deviceName": run.device_name, "groupId": run.group_id}
705
+ with self.lock:
706
+ self.runs[run.id] = thread_id
707
+ try:
708
+ added = self.store.append(thread_id, [item], unless=lambda found: any(
709
+ i.get("kind") == "run" and i.get("runId") == run.id for i in found))
710
+ except ThreadFull:
711
+ log.warning("thread %s is full; run %s isn't in it", thread_id, run.id)
712
+ return None
713
+ if added is None:
714
+ return next(iter(self.store.find(thread_id, kind="run", run_id=run.id)), None)
715
+ thread = self.store.get(thread_id) or {}
716
+ updated = None
717
+ if not thread.get("lastRunId") and thread.get("lastGroupId") == run.group_id:
718
+ updated = self.store.update_thread(thread_id, {"lastRunId": run.id})
719
+ self.published_item(thread_id, added[0])
720
+ if updated is not None:
721
+ self.published_thread(updated, "thread_updated")
722
+ return added[0]
723
+
724
+ def answered(self, thread_id, run_id, approval_id, answer):
725
+ for item in self.store.find(thread_id, kind="clarify", run_id=run_id,
726
+ match=lambda i: i.get("approvalId") == approval_id):
727
+ updated = self.store.update_item(thread_id, item["id"], {"answer": answer})
728
+ if updated is not None:
729
+ self.published_item(thread_id, updated, "thread_item_updated")
730
+
731
+ def set_status(self, thread_id, status):
732
+ thread = self.store.get(thread_id)
733
+ if thread is None or thread.get("status") == status:
734
+ return
735
+ updated = self.store.update_thread(thread_id, {"status": status})
736
+ if updated is not None:
737
+ self.published_thread(updated, "thread_updated")
738
+
739
+ def thread_of(self, run):
740
+ thread_id = self.runs.get(run.id)
741
+ if thread_id is None and isinstance(getattr(run, "extras", None), dict):
742
+ thread_id = run.extras.get("threadId")
743
+ return thread_id
744
+
745
+ def on_created(self, run):
746
+ thread_id = run.extras.get("threadId") if isinstance(run.extras, dict) else None
747
+ if thread_id and self.store.get(thread_id) is not None:
748
+ self.exchange(thread_id, run)
749
+
750
+ def on_event(self, run, event):
751
+ thread_id = self.thread_of(run)
752
+ if not thread_id:
753
+ return
754
+ # The thread view reads everything from its one /api/events stream: the run's events too, as the run SSE
755
+ # sends them.
756
+ self.publish(f"thread:{thread_id}", {"event": "run_event", "runId": run.id, "data": event})
757
+ kind = event.get("event")
758
+ if isinstance(event.get("app_name"), str) and event["app_name"]:
759
+ self.last_app[run.id] = event["app_name"][:80]
760
+ if kind == "user_message":
761
+ source = event.get("source")
762
+ self.add_steer(thread_id, run.id, event.get("id"), event.get("text") or "", ORIGIN_VIA.get(source, "cli"))
763
+ elif kind == "steer_applied":
764
+ ids = set(event.get("ids") or ())
765
+ for item in self.store.find(thread_id, kind="user", run_id=run.id,
766
+ match=lambda i: i.get("messageId") in ids and not i.get("readAt")):
767
+ updated = self.store.update_item(thread_id, item["id"], {"readAt": event.get("timestamp")})
768
+ if updated is not None:
769
+ self.published_item(thread_id, updated, "thread_item_updated")
770
+ elif kind == "steer_unread":
771
+ ids = set(event.get("ids") or ())
772
+ for item in self.store.find(thread_id, kind="user", run_id=run.id,
773
+ match=lambda i: i.get("messageId") in ids):
774
+ updated = self.store.update_item(thread_id, item["id"], {"routed": "unread", "note": UNREAD_NOTE})
775
+ if updated is not None:
776
+ self.published_item(thread_id, updated, "thread_item_updated")
777
+ elif kind == "approval_requested":
778
+ self.set_status(thread_id, "waiting")
779
+ if event.get("kind") == "clarify":
780
+ self.clarify_item(thread_id, run, event)
781
+ if getattr(run, "group_id", None):
782
+ self.group_changed(run)
783
+ elif kind == "approval_resolved":
784
+ if getattr(run, "group_id", None):
785
+ self.group_changed(run) # still waiting while another phone waits
786
+ else:
787
+ self.set_status(thread_id, "running")
788
+ decision = str(event.get("decision") or "")
789
+ for item in self.store.find(thread_id, kind="clarify", run_id=run.id,
790
+ match=lambda i: i.get("approvalId") == event.get("approval_id")):
791
+ changes = {"resolution": "answered" if decision.startswith(("answer", "choice")) else decision}
792
+ if decision.startswith("choice:"):
793
+ choice = decision.split(":", 1)[1]
794
+ label = next((c["label"] for c in item.get("choices") or () if c.get("id") == choice), choice)
795
+ changes["answer"] = item.get("answer") or label
796
+ updated = self.store.update_item(thread_id, item["id"], changes)
797
+ if updated is not None:
798
+ self.published_item(thread_id, updated, "thread_item_updated")
799
+
800
+ def group_changed(self, run):
801
+ """A phone of a group asked or was answered: the group's item and the thread's state follow."""
802
+ fanout = getattr(self.runtime, "fanout", None)
803
+ if fanout is None:
804
+ return
805
+ try:
806
+ group = fanout.get(run.group_id)
807
+ except APIError:
808
+ return
809
+ self.on_group(fanout.public(group), "updated")
810
+
811
+ def clarify_item(self, thread_id, run, event, pending=None):
812
+ approval_id = event.get("approval_id")
813
+ if pending is None:
814
+ with run.condition:
815
+ pending = dict(run.approval) if run.approval and run.approval.get("id") == approval_id else None
816
+ question = (pending or {}).get("question") or event.get("label") or ""
817
+ choices = ([{"id": c["id"], "label": c["label"], **({"detail": c["detail"]} if c.get("detail") else {})}
818
+ for c in (pending or {}).get("choices") or () if isinstance(c, dict)]
819
+ or [{"id": c, "label": c} for c in event.get("choices") or ()])
820
+ item = {"kind": "clarify", "runId": run.id, "approvalId": approval_id, "question": question[:300],
821
+ "choices": choices[:6], "allowText": bool((pending or {}).get("allowText", True))}
822
+ # Answered here before this item existed (the listener runs after the answer): keep the answer.
823
+ answer = self.store.find(thread_id, kind="user", run_id=run.id,
824
+ match=lambda i: i.get("routed") == "answer" and i.get("approvalId") == approval_id)
825
+ if answer:
826
+ item["answer"] = answer[0]["text"]
827
+ try:
828
+ added = self.store.append(thread_id, [item],
829
+ unless=lambda found: any(i.get("approvalId") == approval_id for i in found))
830
+ except ThreadFull:
831
+ return
832
+ if added:
833
+ self.published_item(thread_id, added[0])
834
+
835
+ def on_finished(self, run, summary, private):
836
+ thread_id = self.thread_of(run)
837
+ self.runs.pop(run.id, None)
838
+ last_app = self.last_app.pop(run.id, None)
839
+ if not thread_id or self.store.get(thread_id) is None:
840
+ return
841
+ self.record_result(thread_id, run, summary or {}, private or {}, last_app)
842
+ if run.events and run.events[-1].get("event") == "run_finished":
843
+ self.publish(f"thread:{thread_id}", {"event": "run_event", "runId": run.id, "data": run.events[-1]})
844
+
845
+ def record_result(self, thread_id, run, summary, private, last_app=None):
846
+ """Keep the run's result in its item, so the conversation outlives the run's 100-run retention."""
847
+ items = self.store.find(thread_id, kind="run", run_id=run.id)
848
+ if not items:
849
+ self.exchange(thread_id, run)
850
+ items = self.store.find(thread_id, kind="run", run_id=run.id)
851
+ if not items:
852
+ return
853
+ item = items[0]
854
+ result = result_of(summary, run)
855
+ context = context_of(summary, private, last_app)
856
+ turn = sum(1 for i in self.store.items(thread_id) if i.get("kind") == "run"
857
+ and i.get("seq", 0) <= item.get("seq", 0))
858
+ context["referents"] = [{**ref, "turn": turn, "task": run.goal[:120]} for ref in referents_of(summary)]
859
+ changes = {"result": result, "_context": context}
860
+ if not item.get("appName") and last_app:
861
+ changes["appName"] = last_app
862
+ try:
863
+ updated = self.store.update_item(thread_id, item["id"], changes)
864
+ except ThreadFull:
865
+ updated = self.store.update_item(thread_id, item["id"], {"result": result})
866
+ if updated is not None:
867
+ self.published_item(thread_id, updated, "thread_item_updated")
868
+ thread = self.store.get(thread_id)
869
+ if getattr(run, "group_id", None):
870
+ return # the group's item and the thread's state follow the whole group (on_group)
871
+ if thread is not None and thread.get("lastRunId") in (run.id, None):
872
+ changes = {"status": "idle"}
873
+ updated_thread = self.store.update_thread(thread_id, changes)
874
+ if updated_thread is not None:
875
+ self.published_thread(updated_thread, "thread_updated")
876
+
877
+ def reconcile(self):
878
+ """After a restart: a thread left running or waiting gets its run's result (the run was marked
879
+ interrupted when the journal reloaded) and goes back to idle."""
880
+ try:
881
+ stale = self.store.threads_with_status(("running", "waiting"))
882
+ except Exception: # noqa: BLE001
883
+ log.exception("could not read the conversations")
884
+ return
885
+ for thread in stale:
886
+ run = self.runtime.runs.get(thread.get("lastRunId"))
887
+ if run is not None and run.finished_at is None:
888
+ continue
889
+ if run is not None:
890
+ for item in self.store.find(thread["id"], kind="run", run_id=run.id):
891
+ if not item.get("result"):
892
+ self.store.update_item(thread["id"], item["id"], {"result": result_of(run.summary or {}, run)})
893
+ self.store.update_thread(thread["id"], {"status": "idle"}, touch=False)
894
+
895
+ def sink_append(self, thread_id, item):
896
+ if not isinstance(item, dict) or not isinstance(item.get("kind"), str) or not KIND.fullmatch(item["kind"]):
897
+ raise ValueError("A thread item has a kind of 1 to 40 lowercase letters or underscores")
898
+ if item["kind"] in CORE_KINDS:
899
+ raise ValueError("user, run and clarify items come only from conversations")
900
+ clean = {k: v for k, v in item.items() if k not in ("id", "seq", "at") and not k.startswith("_")}
901
+ if len(json.dumps(clean, ensure_ascii=False, allow_nan=False).encode()) > MAX_SINK_ITEM_BYTES - 200:
902
+ raise ValueError(f"A thread item is at most {MAX_SINK_ITEM_BYTES:,} bytes")
903
+ if self.store.get(thread_id) is None:
904
+ return None
905
+ added = self.store.append(thread_id, [clean])
906
+ if added:
907
+ self.published_item(thread_id, added[0])
908
+ return public_item(added[0])
909
+ return None
910
+
911
+ def sink_update(self, thread_id, item_id, changes):
912
+ if not isinstance(changes, dict):
913
+ raise ValueError("changes is an object")
914
+ clean = {k: v for k, v in changes.items() if not k.startswith("_")}
915
+ found = self.store.find(thread_id, match=lambda i: i.get("id") == item_id)
916
+ if not found or found[0].get("kind") in CORE_KINDS:
917
+ return None
918
+ kinds = {found[0]["kind"]}
919
+ updated = self.store.update_item(thread_id, item_id, clean, kinds=kinds, max_bytes=MAX_SINK_ITEM_BYTES)
920
+ if updated is not None:
921
+ self.published_item(thread_id, updated, "thread_item_updated")
922
+ return public_item(updated)
923
+ return None
924
+
925
+
926
+ class Listener:
927
+ """The run listener conversations registers (harness_api.register_run_listener)."""
928
+
929
+ @staticmethod
930
+ def _service(run):
931
+ for service in services():
932
+ if run.id in service.runs or service.runtime.runs.get(run.id) is run:
933
+ return service
934
+ return None
935
+
936
+ def on_created(self, run, runtime):
937
+ service = service_for(runtime)
938
+ if service is not None and isinstance(run.extras, dict) and run.extras.get("threadId"):
939
+ service.on_created(run)
940
+
941
+ def on_event(self, run, event):
942
+ if not (isinstance(getattr(run, "extras", None), dict) and run.extras.get("threadId")):
943
+ return
944
+ service = self._service(run)
945
+ if service is not None:
946
+ service.on_event(run, event)
947
+
948
+ def on_finished(self, run, summary, private):
949
+ if not (isinstance(getattr(run, "extras", None), dict) and run.extras.get("threadId")):
950
+ return
951
+ service = self._service(run)
952
+ if service is not None:
953
+ service.on_finished(run, summary, private)
954
+
955
+
956
+ def group_listener(runtime, group, event):
957
+ """fanout.add_listener: the group's item in its conversation follows the group."""
958
+ service = service_for(runtime)
959
+ if service is not None:
960
+ service.on_group(group, event)
961
+
962
+
963
+ def sink_append(thread_id, item):
964
+ """harness_api.post_thread_item lands here: the item in the thread of whichever Runtime has it."""
965
+ for service in services():
966
+ if service.store.get(thread_id) is not None:
967
+ return service.sink_append(thread_id, item)
968
+ return None
969
+
970
+
971
+ def sink_update(thread_id, item_id, changes):
972
+ for service in services():
973
+ if service.store.get(thread_id) is not None:
974
+ return service.sink_update(thread_id, item_id, changes)
975
+ return None